mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-20 10:00:58 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user