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