Prechádzať zdrojové kódy

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.
MHSanaei 1 deň pred
rodič
commit
5eec178483

+ 33 - 0
frontend/src/api/queries/useOutboundTags.ts

@@ -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
frontend/src/hooks/useXraySetting.ts

@@ -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');

+ 25 - 0
frontend/src/pages/inbounds/form/protocols/mtproto.tsx

@@ -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>
+      )}
     </>
   );
 }

+ 7 - 0
frontend/src/schemas/protocols/inbound/mtproto.ts

@@ -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>;

+ 23 - 2
internal/mtproto/manager.go

@@ -32,6 +32,12 @@ type Instance struct {
 	FrontingIP            string
 	FrontingPort          int
 	FrontingProxyProtocol bool
+
+	// When RouteThroughXray is set, mtg dials Telegram through the loopback
+	// SOCKS bridge the panel injects into the Xray config at XrayRoutePort, so
+	// the egress obeys the core's routing rules instead of going out directly.
+	RouteThroughXray bool
+	XrayRoutePort    int
 }
 
 func (inst Instance) bindTo() string {
@@ -54,6 +60,8 @@ func (inst Instance) fingerprint() string {
 		inst.FrontingIP,
 		strconv.Itoa(inst.FrontingPort),
 		strconv.FormatBool(inst.FrontingProxyProtocol),
+		strconv.FormatBool(inst.RouteThroughXray),
+		strconv.Itoa(inst.XrayRoutePort),
 	}, "|")
 }
 
@@ -117,6 +125,8 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
 			Port          int    `json:"port"`
 			ProxyProtocol bool   `json:"proxyProtocol"`
 		} `json:"domainFronting"`
+		RouteThroughXray bool `json:"routeThroughXray"`
+		RouteXrayPort    int  `json:"routeXrayPort"`
 	}
 	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
 		return Instance{}, false
@@ -136,6 +146,8 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
 		FrontingIP:            parsed.DomainFronting.IP,
 		FrontingPort:          parsed.DomainFronting.Port,
 		FrontingProxyProtocol: parsed.DomainFronting.ProxyProtocol,
+		RouteThroughXray:      parsed.RouteThroughXray,
+		XrayRoutePort:         parsed.RouteXrayPort,
 	}, true
 }
 
@@ -172,7 +184,7 @@ func (m *Manager) ensureLocked(inst Instance) error {
 		cur.proc.Stop()
 		delete(m.procs, inst.Id)
 	}
-	metricsPort, err := freeLocalPort()
+	metricsPort, err := FreeLocalPort()
 	if err != nil {
 		return err
 	}
@@ -307,7 +319,10 @@ func (m *Manager) CollectTraffic() []Traffic {
 	return out
 }
 
-func freeLocalPort() (int, error) {
+// FreeLocalPort asks the OS for an unused loopback TCP port. It is used both
+// for mtg's metrics endpoint and to allocate the per-inbound SOCKS egress
+// bridge port persisted into mtproto inbound settings.
+func FreeLocalPort() (int, error) {
 	l, err := net.Listen("tcp", "127.0.0.1:0")
 	if err != nil {
 		return 0, err
@@ -345,6 +360,12 @@ func renderConfig(inst Instance, metricsPort int) string {
 			b.WriteString("proxy-protocol = true\n")
 		}
 	}
+	// When the inbound opts into Xray routing, mtg reaches Telegram through the
+	// loopback SOCKS bridge the panel injects into the running Xray config. mtg
+	// only supports SOCKS5 upstreams, which is exactly what the bridge exposes.
+	if inst.RouteThroughXray && inst.XrayRoutePort > 0 {
+		fmt.Fprintf(&b, "\n[network]\nproxies = [\"socks5://127.0.0.1:%d\"]\n", inst.XrayRoutePort)
+	}
 	fmt.Fprintf(&b, "\n[stats.prometheus]\nenabled = true\nbind-to = \"127.0.0.1:%d\"\nhttp-path = \"/metrics\"\nmetric-prefix = \"mtg\"\n", metricsPort)
 	return b.String()
 }

+ 33 - 1
internal/mtproto/manager_test.go

@@ -40,7 +40,8 @@ func TestInstanceFromInbound(t *testing.T) {
 		Protocol: model.MTProto,
 		Settings: `{"fakeTlsDomain":"example.com","secret":"",` +
 			`"debug":true,"proxyProtocolListener":true,"preferIp":"prefer-ipv4",` +
-			`"domainFronting":{"ip":"127.0.0.1","port":9443,"proxyProtocol":true}}`,
+			`"domainFronting":{"ip":"127.0.0.1","port":9443,"proxyProtocol":true},` +
+			`"routeThroughXray":true,"routeXrayPort":50000}`,
 	}
 	inst, ok := InstanceFromInbound(ib)
 	if !ok {
@@ -58,6 +59,9 @@ func TestInstanceFromInbound(t *testing.T) {
 	if inst.FrontingIP != "127.0.0.1" || inst.FrontingPort != 9443 || !inst.FrontingProxyProtocol {
 		t.Fatalf("domain-fronting not parsed: %+v", inst)
 	}
+	if !inst.RouteThroughXray || inst.XrayRoutePort != 50000 {
+		t.Fatalf("xray routing not parsed: %+v", inst)
+	}
 
 	if _, ok := InstanceFromInbound(&model.Inbound{Protocol: model.VLESS}); ok {
 		t.Fatal("non-mtproto inbound should not produce an instance")
@@ -108,6 +112,32 @@ func TestRenderConfig(t *testing.T) {
 	}
 }
 
+func TestRenderConfigXrayEgress(t *testing.T) {
+	// Routing through Xray emits a [network] proxies upstream pointing at the
+	// loopback SOCKS bridge, before the prometheus block.
+	routed := renderConfig(Instance{
+		Secret: "ee22", Listen: "0.0.0.0", Port: 443,
+		RouteThroughXray: true, XrayRoutePort: 50000,
+	}, 7000)
+	if !strings.Contains(routed, "[network]") ||
+		!strings.Contains(routed, `proxies = ["socks5://127.0.0.1:50000"]`) {
+		t.Fatalf("routed config must emit the SOCKS upstream:\n%s", routed)
+	}
+	if strings.Index(routed, "[network]") > strings.Index(routed, "[stats.prometheus]") {
+		t.Fatalf("[network] must precede [stats.prometheus]:\n%s", routed)
+	}
+
+	// Without the flag (or without a port) the section is omitted.
+	for _, inst := range []Instance{
+		{Secret: "ee", Listen: "0.0.0.0", Port: 443},
+		{Secret: "ee", Listen: "0.0.0.0", Port: 443, RouteThroughXray: true},
+	} {
+		if got := renderConfig(inst, 7000); strings.Contains(got, "[network]") {
+			t.Fatalf("unrouted config must omit [network]:\n%s", got)
+		}
+	}
+}
+
 func TestFingerprintReactsToOptions(t *testing.T) {
 	base := Instance{Secret: "ee", Listen: "0.0.0.0", Port: 443}
 	for name, mutate := range map[string]func(*Instance){
@@ -117,6 +147,8 @@ func TestFingerprintReactsToOptions(t *testing.T) {
 		"frontingIP":    func(i *Instance) { i.FrontingIP = "127.0.0.1" },
 		"frontingPort":  func(i *Instance) { i.FrontingPort = 9443 },
 		"frontingProxy": func(i *Instance) { i.FrontingProxyProtocol = true },
+		"routeXray":     func(i *Instance) { i.RouteThroughXray = true },
+		"routeXrayPort": func(i *Instance) { i.XrayRoutePort = 50000 },
 	} {
 		changed := base
 		mutate(&changed)

+ 13 - 0
internal/web/job/mtproto_job.go

@@ -31,12 +31,16 @@ func (j *MtprotoJob) Run() {
 	}
 
 	var desired []mtproto.Instance
+	routedTags := make(map[string]bool)
 	for _, ib := range inbounds {
 		if ib.Protocol != model.MTProto || !ib.Enable || ib.NodeID != nil {
 			continue
 		}
 		if inst, ok := mtproto.InstanceFromInbound(ib); ok {
 			desired = append(desired, inst)
+			if inst.RouteThroughXray {
+				routedTags[inst.Tag] = true
+			}
 		}
 	}
 
@@ -49,6 +53,12 @@ func (j *MtprotoJob) Run() {
 	}
 	traffics := make([]*xray.Traffic, 0, len(deltas))
 	for _, d := range deltas {
+		// Routed inbounds egress through the Xray SOCKS bridge, which carries the
+		// inbound's tag and is metered by xray_traffic_job. Folding mtg's own
+		// metrics in too would double-count, so skip them here.
+		if routedTags[d.Tag] {
+			continue
+		}
 		traffics = append(traffics, &xray.Traffic{
 			IsInbound: true,
 			Tag:       d.Tag,
@@ -56,6 +66,9 @@ func (j *MtprotoJob) Run() {
 			Down:      d.Down,
 		})
 	}
+	if len(traffics) == 0 {
+		return
+	}
 	if _, _, err := j.inboundService.AddTraffic(traffics, nil); err != nil {
 		logger.Warning("mtproto job: add traffic failed:", err)
 	}

+ 129 - 0
internal/web/service/inbound.go

@@ -14,6 +14,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
@@ -451,6 +452,108 @@ func (s *InboundService) normalizeMtprotoSecret(inbound *model.Inbound) {
 	}
 }
 
+// mtprotoRoutesThroughXray reports whether an mtproto inbound is configured to
+// egress through the core's router (the loopback SOCKS bridge in §xray.go).
+func mtprotoRoutesThroughXray(inbound *model.Inbound) bool {
+	if inbound == nil || inbound.Protocol != model.MTProto {
+		return false
+	}
+	var parsed struct {
+		RouteThroughXray bool `json:"routeThroughXray"`
+	}
+	if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil {
+		return false
+	}
+	return parsed.RouteThroughXray
+}
+
+func settingsRouteXrayPort(parsed map[string]any) int {
+	switch v := parsed["routeXrayPort"].(type) {
+	case float64:
+		return int(v)
+	case int:
+		return v
+	case json.Number:
+		if n, err := v.Int64(); err == nil {
+			return int(n)
+		}
+	}
+	return 0
+}
+
+func parseRouteXrayPort(settings string) int {
+	if settings == "" {
+		return 0
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+		return 0
+	}
+	return settingsRouteXrayPort(parsed)
+}
+
+// normalizeMtprotoXrayPort guarantees a routed mtproto inbound carries a stable
+// loopback egress port in its settings, so the generated Xray SOCKS bridge and
+// the mtg sidecar agree on where mtg dials out. The port is backend-owned: it is
+// allocated once when routing is first enabled and preserved across edits
+// (carried over from oldSettings, which wins over any value the client echoed
+// back). When routing is off it — together with the now-inert outbound
+// selection — is stripped so a disabled bridge leaves nothing stale behind.
+//
+// It returns an error when an egress port cannot be allocated or persisted, so
+// the caller refuses the save rather than storing a routed-but-portless inbound,
+// which would otherwise route no traffic and have its mtg metrics skipped (see
+// mtproto_job) — silently losing its accounting.
+func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSettings string) error {
+	if inbound.Protocol != model.MTProto {
+		return nil
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed == nil {
+		return nil
+	}
+	routed, _ := parsed["routeThroughXray"].(bool)
+	if !routed {
+		_, hadPort := parsed["routeXrayPort"]
+		_, hadTag := parsed["outboundTag"]
+		if !hadPort && !hadTag {
+			return nil
+		}
+		delete(parsed, "routeXrayPort")
+		delete(parsed, "outboundTag")
+		if bs, err := json.MarshalIndent(parsed, "", "  "); err == nil {
+			inbound.Settings = string(bs)
+		} else {
+			logger.Warning("mtproto: failed to marshal settings after disabling routing:", err)
+		}
+		return nil
+	}
+
+	// Prefer the already-stored port (carried across edits), then any value the
+	// client sent, then allocate a fresh one.
+	port := parseRouteXrayPort(oldSettings)
+	if port <= 0 {
+		port = settingsRouteXrayPort(parsed)
+	}
+	if port <= 0 {
+		allocated, err := mtproto.FreeLocalPort()
+		if err != nil {
+			return common.NewError("mtproto: could not allocate an Xray egress port:", err)
+		}
+		port = allocated
+	}
+	if settingsRouteXrayPort(parsed) == port {
+		return nil
+	}
+	parsed["routeXrayPort"] = port
+	bs, err := json.MarshalIndent(parsed, "", "  ")
+	if err != nil {
+		return common.NewError("mtproto: could not persist the Xray egress port:", err)
+	}
+	inbound.Settings = string(bs)
+	return nil
+}
+
 // AddInbound creates a new inbound configuration.
 // It validates port uniqueness, client email uniqueness, and required fields,
 // then saves the inbound to the database and optionally adds it to the running Xray instance.
@@ -459,6 +562,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 	// Normalize streamSettings based on protocol
 	s.normalizeStreamSettings(inbound)
 	s.normalizeMtprotoSecret(inbound)
+	if err := s.normalizeMtprotoXrayPort(inbound, ""); err != nil {
+		return inbound, false, err
+	}
 	inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
 	if err := normalizeInboundShareAddressStrict(inbound); err != nil {
 		return inbound, false, err
@@ -622,6 +728,13 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 		}
 	}
 
+	// A routed mtproto inbound is not an Xray inbound itself, so the runtime
+	// push above only (re)starts the mtg sidecar. The egress SOCKS bridge lives
+	// in the generated config, so force a regen to wire it in.
+	if mtprotoRoutesThroughXray(inbound) {
+		needRestart = true
+	}
+
 	return inbound, needRestart, err
 }
 
@@ -685,6 +798,10 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
 			}
 		}
 	}
+	// Drop the egress SOCKS bridge a routed mtproto inbound left in the config.
+	if mtprotoRoutesThroughXray(&ib) {
+		needRestart = true
+	}
 	return needRestart, nil
 }
 
@@ -827,6 +944,13 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 		return inbound, false, err
 	}
 	inbound.NodeID = oldInbound.NodeID
+	// Capture the pre-edit routing state before oldInbound.Settings is replaced
+	// with the new settings further down, then ensure a routed inbound keeps a
+	// stable egress port (reusing the one already stored).
+	oldRoutedMtproto := mtprotoRoutesThroughXray(oldInbound)
+	if err := s.normalizeMtprotoXrayPort(inbound, oldInbound.Settings); err != nil {
+		return inbound, false, err
+	}
 
 	tag := oldInbound.Tag
 	oldBits := inboundTransports(oldInbound.Protocol, oldInbound.StreamSettings, oldInbound.Settings)
@@ -1009,6 +1133,11 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	if err = s.clientService.SyncInbound(tx, oldInbound.Id, newClients); err != nil {
 		return inbound, false, err
 	}
+	// (Re)generate the Xray config whenever routing was or is now enabled, so the
+	// egress SOCKS bridge is added, moved, or dropped to match the new settings.
+	if mtprotoRoutesThroughXray(inbound) || oldRoutedMtproto {
+		needRestart = true
+	}
 	return inbound, needRestart, nil
 }
 

+ 94 - 0
internal/web/service/inbound_mtproto_test.go

@@ -0,0 +1,94 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestMtprotoRoutesThroughXray(t *testing.T) {
+	cases := map[string]struct {
+		ib   *model.Inbound
+		want bool
+	}{
+		"routed":      {&model.Inbound{Protocol: model.MTProto, Settings: `{"routeThroughXray":true}`}, true},
+		"off":         {&model.Inbound{Protocol: model.MTProto, Settings: `{"routeThroughXray":false}`}, false},
+		"absent":      {&model.Inbound{Protocol: model.MTProto, Settings: `{}`}, false},
+		"non-mtproto": {&model.Inbound{Protocol: model.VLESS, Settings: `{"routeThroughXray":true}`}, false},
+		"bad json":    {&model.Inbound{Protocol: model.MTProto, Settings: `{nope`}, false},
+		"nil":         {nil, false},
+	}
+	for name, c := range cases {
+		if got := mtprotoRoutesThroughXray(c.ib); got != c.want {
+			t.Fatalf("%s: got %v want %v", name, got, c.want)
+		}
+	}
+}
+
+func routeXrayPortOf(t *testing.T, settings string) int {
+	t.Helper()
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+		t.Fatalf("settings not valid JSON: %v\n%s", err, settings)
+	}
+	return settingsRouteXrayPort(parsed)
+}
+
+func TestNormalizeMtprotoXrayPort(t *testing.T) {
+	s := &InboundService{}
+
+	// Non-mtproto inbounds are left alone.
+	ib := &model.Inbound{Protocol: model.VLESS, Settings: `{"x":1}`}
+	if err := s.normalizeMtprotoXrayPort(ib, ""); err != nil {
+		t.Fatal(err)
+	}
+	if ib.Settings != `{"x":1}` {
+		t.Fatalf("non-mtproto settings must be untouched, got %s", ib.Settings)
+	}
+
+	// Routing on with no existing port allocates a fresh one.
+	ib = &model.Inbound{Protocol: model.MTProto, Settings: `{"routeThroughXray":true}`}
+	if err := s.normalizeMtprotoXrayPort(ib, ""); err != nil {
+		t.Fatal(err)
+	}
+	if p := routeXrayPortOf(t, ib.Settings); p <= 0 {
+		t.Fatalf("a routed inbound must get a port, got %d", p)
+	}
+
+	// On update, the stored port wins over both a missing and a client-echoed
+	// value — the backend owns it, so no churn and no client override.
+	ib = &model.Inbound{Protocol: model.MTProto, Settings: `{"routeThroughXray":true,"routeXrayPort":99999}`}
+	if err := s.normalizeMtprotoXrayPort(ib, `{"routeThroughXray":true,"routeXrayPort":51000}`); err != nil {
+		t.Fatal(err)
+	}
+	if p := routeXrayPortOf(t, ib.Settings); p != 51000 {
+		t.Fatalf("stored port must win, got %d", p)
+	}
+
+	// An already-present port (no old settings) is stable and not re-marshaled.
+	const stable = `{"routeThroughXray":true,"routeXrayPort":52000}`
+	ib = &model.Inbound{Protocol: model.MTProto, Settings: stable}
+	if err := s.normalizeMtprotoXrayPort(ib, ""); err != nil {
+		t.Fatal(err)
+	}
+	if ib.Settings != stable {
+		t.Fatalf("stable settings must pass through untouched, got %s", ib.Settings)
+	}
+
+	// Turning routing off strips both the bridge port and the inert outbound.
+	ib = &model.Inbound{Protocol: model.MTProto, Settings: `{"routeThroughXray":false,"routeXrayPort":53000,"outboundTag":"warp"}`}
+	if err := s.normalizeMtprotoXrayPort(ib, ""); err != nil {
+		t.Fatal(err)
+	}
+	if p := routeXrayPortOf(t, ib.Settings); p != 0 {
+		t.Fatalf("disabling routing must drop the port, got %d", p)
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(ib.Settings), &parsed); err != nil {
+		t.Fatal(err)
+	}
+	if _, ok := parsed["outboundTag"]; ok {
+		t.Fatalf("disabling routing must drop the inert outbound tag, got %s", ib.Settings)
+	}
+}

+ 82 - 0
internal/web/service/xray.go

@@ -275,6 +275,19 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 		mergeSubscriptionOutbounds(xrayConfig, prepend, appendList)
 	}
 
+	// Route opted-in local mtproto inbounds through the core's router. Each one
+	// gets a loopback SOCKS bridge — tagged with the inbound's own tag so it is
+	// matchable in routing rules — that its mtg sidecar dials Telegram through.
+	// Done after the subscription merge so a selected subscription outbound (or
+	// balancer) is a valid rule target.
+	for i := range inbounds {
+		inbound := inbounds[i]
+		if inbound.Protocol != model.MTProto || !inbound.Enable || inbound.NodeID != nil {
+			continue
+		}
+		injectMtprotoEgress(xrayConfig, inbound)
+	}
+
 	// Wire the panel's own HTTP traffic through the configured outbound, after
 	// the subscription merge so subscription outbound tags are valid targets.
 	if egressTag, err := s.settingService.GetPanelOutbound(); err != nil {
@@ -382,6 +395,75 @@ func routingTagIsBalancer(routing map[string]any, tag string) bool {
 	return false
 }
 
+// mtprotoEgressSocksSettings is the loopback SOCKS server a routed mtproto
+// inbound exposes for its mtg sidecar to dial Telegram through. mtg makes plain
+// TCP connections, so UDP is left off (matching the panel egress bridge).
+const mtprotoEgressSocksSettings = `{"auth":"noauth","udp":false}`
+
+// injectMtprotoEgress wires one routed mtproto inbound into the generated
+// config: it appends a loopback SOCKS inbound (tagged with the inbound's own tag,
+// on the egress port persisted in settings) and, when an outbound is selected,
+// prepends a routing rule sending that tag to it. Both live only in the generated
+// config — the stored template is untouched — and both are hot-appliable, so
+// toggling routing never forces a full Xray restart. Mirrors injectPanelEgress.
+func injectMtprotoEgress(cfg *xray.Config, inbound *model.Inbound) {
+	var parsed struct {
+		RouteThroughXray bool   `json:"routeThroughXray"`
+		RouteXrayPort    int    `json:"routeXrayPort"`
+		OutboundTag      string `json:"outboundTag"`
+	}
+	if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil {
+		return
+	}
+	if !parsed.RouteThroughXray || parsed.RouteXrayPort <= 0 || inbound.Tag == "" {
+		return
+	}
+	tag := inbound.Tag
+	for i := range cfg.InboundConfigs {
+		if cfg.InboundConfigs[i].Tag == tag {
+			logger.Warning("mtproto egress: inbound tag [", tag, "] already present in generated config, skipping bridge")
+			return
+		}
+	}
+
+	if parsed.OutboundTag != "" {
+		routing := map[string]any{}
+		parseOK := true
+		if len(cfg.RouterConfig) > 0 {
+			if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+				logger.Warning("mtproto egress: routing section is unparsable, skipping rule:", err)
+				parseOK = false
+			}
+		}
+		if parseOK {
+			rules, _ := routing["rules"].([]any)
+			rule := map[string]any{
+				"type":       "field",
+				"inboundTag": []any{tag},
+			}
+			if routingTagIsBalancer(routing, parsed.OutboundTag) {
+				rule["balancerTag"] = parsed.OutboundTag
+			} else {
+				rule["outboundTag"] = parsed.OutboundTag
+			}
+			routing["rules"] = append([]any{rule}, rules...)
+			if newRouting, err := json.Marshal(routing); err == nil {
+				cfg.RouterConfig = json_util.RawMessage(newRouting)
+			} else {
+				logger.Warning("mtproto egress: failed to rebuild routing section, skipping rule:", err)
+			}
+		}
+	}
+
+	cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
+		Listen:   json_util.RawMessage(`"127.0.0.1"`),
+		Port:     parsed.RouteXrayPort,
+		Protocol: "socks",
+		Settings: json_util.RawMessage(mtprotoEgressSocksSettings),
+		Tag:      tag,
+	})
+}
+
 // mergeSubscriptionOutbounds appends the subscription outbounds to the
 // OutboundConfigs array of the xray config. It works on the already-unmarshaled
 // template so that manually configured outbounds are never overwritten.

+ 97 - 0
internal/web/service/xray_config_inject_test.go

@@ -5,6 +5,7 @@ import (
 	"os"
 	"testing"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
@@ -271,3 +272,99 @@ func TestInjectPanelEgress_BadRoutingSkips(t *testing.T) {
 		t.Fatal("unparsable routing must be left untouched")
 	}
 }
+
+func mtprotoInbound(tag string, settings string) *model.Inbound {
+	return &model.Inbound{Tag: tag, Protocol: model.MTProto, Enable: true, Settings: settings}
+}
+
+func TestInjectMtprotoEgress_WithOutbound(t *testing.T) {
+	cfg := egressTestConfig()
+	injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
+		`{"routeThroughXray":true,"routeXrayPort":50000,"outboundTag":"warp"}`))
+
+	if len(cfg.InboundConfigs) != 2 {
+		t.Fatalf("expected the bridge inbound to be appended, got %d", len(cfg.InboundConfigs))
+	}
+	ib := cfg.InboundConfigs[1]
+	if ib.Tag != "inbound-443" || ib.Protocol != "socks" || ib.Port != 50000 {
+		t.Fatalf("unexpected bridge inbound: %+v", ib)
+	}
+	if string(ib.Listen) != `"127.0.0.1"` {
+		t.Fatalf("bridge must listen on loopback, got %s", ib.Listen)
+	}
+
+	var routing egressRouting
+	if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+		t.Fatal(err)
+	}
+	if len(routing.Rules) != 2 {
+		t.Fatalf("expected the egress rule prepended to the existing rule, got %+v", routing.Rules)
+	}
+	first := routing.Rules[0]
+	if first.Type != "field" || first.OutboundTag != "warp" ||
+		len(first.InboundTag) != 1 || first.InboundTag[0] != "inbound-443" {
+		t.Fatalf("egress rule must bind the inbound tag to the outbound, got %+v", first)
+	}
+}
+
+func TestInjectMtprotoEgress_NoOutboundLeavesRouting(t *testing.T) {
+	cfg := egressTestConfig()
+	before := string(cfg.RouterConfig)
+	injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
+		`{"routeThroughXray":true,"routeXrayPort":50001}`))
+
+	if len(cfg.InboundConfigs) != 2 || cfg.InboundConfigs[1].Port != 50001 {
+		t.Fatalf("bridge must still be appended without an outbound, got %+v", cfg.InboundConfigs)
+	}
+	if string(cfg.RouterConfig) != before {
+		t.Fatalf("no outbound means no rule change, got %s", cfg.RouterConfig)
+	}
+}
+
+func TestInjectMtprotoEgress_BalancerTag(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.RouterConfig = json_util.RawMessage(`{"rules":[],"balancers":[{"tag":"lb","selector":["warp"]}]}`)
+	injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
+		`{"routeThroughXray":true,"routeXrayPort":50002,"outboundTag":"lb"}`))
+
+	var routing struct {
+		Rules []struct {
+			OutboundTag string `json:"outboundTag"`
+			BalancerTag string `json:"balancerTag"`
+		} `json:"rules"`
+	}
+	if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+		t.Fatal(err)
+	}
+	if len(routing.Rules) != 1 || routing.Rules[0].BalancerTag != "lb" || routing.Rules[0].OutboundTag != "" {
+		t.Fatalf("a balancer tag must target balancerTag, got %+v", routing.Rules)
+	}
+}
+
+func TestInjectMtprotoEgress_Disabled(t *testing.T) {
+	// Not routed, and routed-but-portless, are both no-ops.
+	for _, settings := range []string{
+		`{"routeThroughXray":false,"routeXrayPort":50000}`,
+		`{"routeThroughXray":true}`,
+		`{"routeThroughXray":true,"routeXrayPort":0}`,
+	} {
+		cfg := egressTestConfig()
+		before := string(cfg.RouterConfig)
+		injectMtprotoEgress(cfg, mtprotoInbound("inbound-443", settings))
+		if len(cfg.InboundConfigs) != 1 || string(cfg.RouterConfig) != before {
+			t.Fatalf("settings %s must be a no-op, got %d inbounds", settings, len(cfg.InboundConfigs))
+		}
+	}
+}
+
+func TestInjectMtprotoEgress_TagCollisionSkips(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.InboundConfigs = append(cfg.InboundConfigs,
+		xray.InboundConfig{Port: 443, Protocol: "vless", Tag: "inbound-443"})
+	before := string(cfg.RouterConfig)
+	injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
+		`{"routeThroughXray":true,"routeXrayPort":50003,"outboundTag":"warp"}`))
+	if len(cfg.InboundConfigs) != 2 || string(cfg.RouterConfig) != before {
+		t.Fatal("a real inbound already owning the tag must make the bridge a no-op")
+	}
+}

+ 5 - 0
internal/web/translation/ar-EG.json

@@ -485,6 +485,11 @@
         "mtgProxyProtocolListener": "قبول بروتوكول PROXY (المستمع)",
         "mtgPreferIp": "تفضيل IP",
         "mtgDebug": "سجل التصحيح",
+        "mtgRouteThroughXray": "التوجيه عبر Xray",
+        "mtgRouteThroughXrayHint": "أرسل حركة Telegram لهذا البروكسي عبر Xray ليتبع قواعد التوجيه لديك. يتصل وسيط mtg عبر جسر SOCKS محلي يحمل وسم هذا الاتصال الوارد؛ استخدم ذلك الوسم في تبويب التوجيه للقواعد المتقدمة.",
+        "mtgRouteOutbound": "الصادر",
+        "mtgRouteOutboundHint": "اختياري. إجبار حركة Telegram على الخروج عبر هذا الصادر (أو الموازِن). اتركه فارغًا لتقرر قواعد التوجيه.",
+        "mtgRouteOutboundPlaceholder": "استخدام قواعد التوجيه",
         "visionTestseed": "Vision testseed",
         "version": "الإصدار",
         "udpIdleTimeout": "UDP idle timeout (ثانية)",

+ 5 - 0
internal/web/translation/en-US.json

@@ -486,6 +486,11 @@
         "mtgProxyProtocolListener": "Accept PROXY protocol (listener)",
         "mtgPreferIp": "IP preference",
         "mtgDebug": "Debug logging",
+        "mtgRouteThroughXray": "Route through Xray",
+        "mtgRouteThroughXrayHint": "Send this proxy's Telegram traffic through Xray so it follows your routing rules. The mtg sidecar dials out via a loopback SOCKS bridge tagged with this inbound's tag; reference that tag in the Routing tab for advanced rules.",
+        "mtgRouteOutbound": "Outbound",
+        "mtgRouteOutboundHint": "Optional. Force Telegram traffic out through this outbound (or balancer). Leave empty to let your routing rules decide.",
+        "mtgRouteOutboundPlaceholder": "Use routing rules",
         "visionTestseed": "Vision testseed",
         "version": "Version",
         "udpIdleTimeout": "UDP idle timeout (s)",

+ 5 - 0
internal/web/translation/es-ES.json

@@ -485,6 +485,11 @@
         "mtgProxyProtocolListener": "Aceptar protocolo PROXY (escucha)",
         "mtgPreferIp": "Preferencia de IP",
         "mtgDebug": "Registro de depuración",
+        "mtgRouteThroughXray": "Enrutar a través de Xray",
+        "mtgRouteThroughXrayHint": "Envía el tráfico de Telegram de este proxy a través de Xray para que siga tus reglas de enrutamiento. El sidecar mtg sale por un puente SOCKS local con la etiqueta de esta entrada; usa esa etiqueta en la pestaña Enrutamiento para reglas avanzadas.",
+        "mtgRouteOutbound": "Salida",
+        "mtgRouteOutboundHint": "Opcional. Fuerza el tráfico de Telegram a salir por esta salida (o balanceador). Déjalo vacío para que decidan tus reglas de enrutamiento.",
+        "mtgRouteOutboundPlaceholder": "Usar reglas de enrutamiento",
         "visionTestseed": "Vision testseed",
         "version": "Versión",
         "udpIdleTimeout": "UDP idle timeout (s)",

+ 5 - 0
internal/web/translation/fa-IR.json

@@ -485,6 +485,11 @@
         "mtgProxyProtocolListener": "پذیرش پروتکل PROXY (شنونده)",
         "mtgPreferIp": "ترجیح IP",
         "mtgDebug": "گزارش اشکال‌زدایی",
+        "mtgRouteThroughXray": "مسیریابی از طریق Xray",
+        "mtgRouteThroughXrayHint": "ترافیک تلگرام این پراکسی را از طریق Xray بفرستید تا از قوانین مسیریابی شما پیروی کند. سرویس جانبی mtg از طریق یک پل SOCKS محلی که با تگ همین ورودی نشانه‌گذاری شده خارج می‌شود؛ برای قوانین پیشرفته در تب مسیریابی به همان تگ ارجاع دهید.",
+        "mtgRouteOutbound": "خروجی",
+        "mtgRouteOutboundHint": "اختیاری. ترافیک تلگرام را وادار کنید از این خروجی (یا متعادل‌کننده) خارج شود. برای اینکه قوانین مسیریابی تصمیم بگیرند، خالی بگذارید.",
+        "mtgRouteOutboundPlaceholder": "استفاده از قوانین مسیریابی",
         "visionTestseed": "Vision testseed",
         "version": "نسخه",
         "udpIdleTimeout": "UDP idle timeout (s)",

+ 5 - 0
internal/web/translation/id-ID.json

@@ -485,6 +485,11 @@
         "mtgProxyProtocolListener": "Terima protokol PROXY (listener)",
         "mtgPreferIp": "Preferensi IP",
         "mtgDebug": "Log debug",
+        "mtgRouteThroughXray": "Rutekan melalui Xray",
+        "mtgRouteThroughXrayHint": "Kirim lalu lintas Telegram proxy ini melalui Xray agar mengikuti aturan routing Anda. Sidecar mtg keluar lewat bridge SOCKS loopback yang diberi tag sama dengan inbound ini; rujuk tag tersebut di tab Routing untuk aturan lanjutan.",
+        "mtgRouteOutbound": "Outbound",
+        "mtgRouteOutboundHint": "Opsional. Paksa lalu lintas Telegram keluar melalui outbound (atau balancer) ini. Biarkan kosong agar aturan routing yang menentukan.",
+        "mtgRouteOutboundPlaceholder": "Gunakan aturan routing",
         "visionTestseed": "Vision testseed",
         "version": "Versi",
         "udpIdleTimeout": "UDP idle timeout (d)",

+ 5 - 0
internal/web/translation/ja-JP.json

@@ -485,6 +485,11 @@
         "mtgProxyProtocolListener": "PROXY プロトコルを受け入れる(リスナー)",
         "mtgPreferIp": "IP の優先設定",
         "mtgDebug": "デバッグログ",
+        "mtgRouteThroughXray": "Xray 経由でルーティング",
+        "mtgRouteThroughXrayHint": "このプロキシの Telegram トラフィックを Xray 経由にして、ルーティングルールに従わせます。mtg サイドカーは、この受信のタグを付けたループバック SOCKS ブリッジ経由で接続します。高度なルールでは、ルーティングタブでそのタグを参照してください。",
+        "mtgRouteOutbound": "アウトバウンド",
+        "mtgRouteOutboundHint": "任意。Telegram トラフィックをこのアウトバウンド(またはバランサー)から強制的に送出します。空欄にするとルーティングルールに従います。",
+        "mtgRouteOutboundPlaceholder": "ルーティングルールを使用",
         "visionTestseed": "Vision testseed",
         "version": "バージョン",
         "udpIdleTimeout": "UDP idle timeout (秒)",

+ 5 - 0
internal/web/translation/pt-BR.json

@@ -485,6 +485,11 @@
         "mtgProxyProtocolListener": "Aceitar protocolo PROXY (listener)",
         "mtgPreferIp": "Preferência de IP",
         "mtgDebug": "Log de depuração",
+        "mtgRouteThroughXray": "Rotear pelo Xray",
+        "mtgRouteThroughXrayHint": "Envie o tráfego do Telegram deste proxy pelo Xray para que ele siga suas regras de roteamento. O sidecar mtg sai por uma ponte SOCKS loopback com a tag deste inbound; use essa tag na aba Roteamento para regras avançadas.",
+        "mtgRouteOutbound": "Saída",
+        "mtgRouteOutboundHint": "Opcional. Force o tráfego do Telegram a sair por esta saída (ou balanceador). Deixe vazio para que suas regras de roteamento decidam.",
+        "mtgRouteOutboundPlaceholder": "Usar regras de roteamento",
         "visionTestseed": "Vision testseed",
         "version": "Versão",
         "udpIdleTimeout": "UDP idle timeout (s)",

+ 5 - 0
internal/web/translation/ru-RU.json

@@ -485,6 +485,11 @@
         "mtgProxyProtocolListener": "Принимать PROXY-протокол (слушатель)",
         "mtgPreferIp": "Предпочтение IP",
         "mtgDebug": "Журнал отладки",
+        "mtgRouteThroughXray": "Маршрутизация через Xray",
+        "mtgRouteThroughXrayHint": "Направляйте трафик Telegram этого прокси через Xray, чтобы он подчинялся вашим правилам маршрутизации. Сайдкар mtg выходит через локальный SOCKS-мост с тегом этого входящего подключения; используйте этот тег на вкладке «Маршрутизация» для расширенных правил.",
+        "mtgRouteOutbound": "Исходящее",
+        "mtgRouteOutboundHint": "Необязательно. Принудительно направить трафик Telegram через это исходящее соединение (или балансировщик). Оставьте пустым, чтобы решали ваши правила маршрутизации.",
+        "mtgRouteOutboundPlaceholder": "Использовать правила маршрутизации",
         "visionTestseed": "Vision testseed",
         "version": "Версия",
         "udpIdleTimeout": "UDP idle timeout (с)",

+ 5 - 0
internal/web/translation/tr-TR.json

@@ -486,6 +486,11 @@
         "mtgProxyProtocolListener": "PROXY protokolünü kabul et (dinleyici)",
         "mtgPreferIp": "IP tercihi",
         "mtgDebug": "Hata ayıklama günlüğü",
+        "mtgRouteThroughXray": "Xray üzerinden yönlendir",
+        "mtgRouteThroughXrayHint": "Bu proxy'nin Telegram trafiğini Xray üzerinden geçirerek yönlendirme kurallarınıza uymasını sağlayın. mtg yardımcı süreci, bu gelen bağlantının etiketini taşıyan bir loopback SOCKS köprüsü üzerinden çıkış yapar; gelişmiş kurallar için Yönlendirme sekmesinde bu etiketi kullanın.",
+        "mtgRouteOutbound": "Giden",
+        "mtgRouteOutboundHint": "İsteğe bağlı. Telegram trafiğini bu giden bağlantı (veya dengeleyici) üzerinden çıkmaya zorlar. Yönlendirme kurallarınızın karar vermesi için boş bırakın.",
+        "mtgRouteOutboundPlaceholder": "Yönlendirme kurallarını kullan",
         "visionTestseed": "Vision Testseed",
         "version": "Sürüm",
         "udpIdleTimeout": "UDP Idle Timeout (s)",

+ 5 - 0
internal/web/translation/uk-UA.json

@@ -485,6 +485,11 @@
         "mtgProxyProtocolListener": "Приймати PROXY-протокол (слухач)",
         "mtgPreferIp": "Перевага IP",
         "mtgDebug": "Журнал налагодження",
+        "mtgRouteThroughXray": "Маршрутизація через Xray",
+        "mtgRouteThroughXrayHint": "Спрямуйте трафік Telegram цього проксі через Xray, щоб він підкорявся вашим правилам маршрутизації. Сайдкар mtg виходить через локальний SOCKS-міст із тегом цього вхідного підключення; використовуйте цей тег на вкладці «Маршрутизація» для розширених правил.",
+        "mtgRouteOutbound": "Вихідне",
+        "mtgRouteOutboundHint": "Необов'язково. Примусово спрямувати трафік Telegram через це вихідне з'єднання (або балансувальник). Залиште порожнім, щоб вирішували ваші правила маршрутизації.",
+        "mtgRouteOutboundPlaceholder": "Використовувати правила маршрутизації",
         "visionTestseed": "Vision testseed",
         "version": "Версія",
         "udpIdleTimeout": "UDP idle timeout (с)",

+ 5 - 0
internal/web/translation/vi-VN.json

@@ -485,6 +485,11 @@
         "mtgProxyProtocolListener": "Chấp nhận giao thức PROXY (trình lắng nghe)",
         "mtgPreferIp": "Ưu tiên IP",
         "mtgDebug": "Nhật ký gỡ lỗi",
+        "mtgRouteThroughXray": "Định tuyến qua Xray",
+        "mtgRouteThroughXrayHint": "Gửi lưu lượng Telegram của proxy này qua Xray để tuân theo các quy tắc định tuyến của bạn. Tiến trình phụ mtg đi ra qua một cầu SOCKS loopback mang thẻ của inbound này; tham chiếu thẻ đó trong tab Định tuyến cho các quy tắc nâng cao.",
+        "mtgRouteOutbound": "Outbound",
+        "mtgRouteOutboundHint": "Tùy chọn. Buộc lưu lượng Telegram đi ra qua outbound (hoặc bộ cân bằng) này. Để trống để các quy tắc định tuyến của bạn quyết định.",
+        "mtgRouteOutboundPlaceholder": "Dùng quy tắc định tuyến",
         "visionTestseed": "Vision testseed",
         "version": "Phiên bản",
         "udpIdleTimeout": "UDP idle timeout (s)",

+ 5 - 0
internal/web/translation/zh-CN.json

@@ -485,6 +485,11 @@
         "mtgProxyProtocolListener": "接受 PROXY 协议(监听器)",
         "mtgPreferIp": "IP 优先级",
         "mtgDebug": "调试日志",
+        "mtgRouteThroughXray": "通过 Xray 路由",
+        "mtgRouteThroughXrayHint": "让此代理的 Telegram 流量经过 Xray,以应用您的路由规则。mtg 附属进程会通过带有此入站标签的本地 SOCKS 桥接出站;在路由选项卡中引用该标签即可设置高级规则。",
+        "mtgRouteOutbound": "出站",
+        "mtgRouteOutboundHint": "可选。强制 Telegram 流量经由此出站(或负载均衡器)发出。留空则由您的路由规则决定。",
+        "mtgRouteOutboundPlaceholder": "使用路由规则",
         "visionTestseed": "Vision testseed",
         "version": "版本",
         "udpIdleTimeout": "UDP 空闲超时 (s)",

+ 5 - 0
internal/web/translation/zh-TW.json

@@ -485,6 +485,11 @@
         "mtgProxyProtocolListener": "接受 PROXY 協定(監聽器)",
         "mtgPreferIp": "IP 偏好",
         "mtgDebug": "除錯日誌",
+        "mtgRouteThroughXray": "透過 Xray 路由",
+        "mtgRouteThroughXrayHint": "讓此代理的 Telegram 流量經過 Xray,以套用您的路由規則。mtg 附屬程序會透過帶有此入站標籤的本機 SOCKS 橋接出站;在路由分頁中引用該標籤即可設定進階規則。",
+        "mtgRouteOutbound": "出站",
+        "mtgRouteOutboundHint": "選填。強制 Telegram 流量經由此出站(或負載平衡器)送出。留空則由您的路由規則決定。",
+        "mtgRouteOutboundPlaceholder": "使用路由規則",
         "visionTestseed": "Vision testseed",
         "version": "版本",
         "udpIdleTimeout": "UDP 閒置逾時 (s)",