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
+129
View File
@@ -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
}
@@ -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
View File
@@ -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.
@@ -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")
}
}