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
+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.