feat(mtproto): add domain-fronting and essential mtg options

Expose mtg's [domain-fronting] section (ip/port/proxy-protocol) plus
proxy-protocol-listener, prefer-ip, and debug on MTProto inbounds. Each
key is written to the generated mtg-<id>.toml only when set, so mtg's own
defaults apply otherwise. The instance fingerprint now covers these
fields, so editing an option restarts the sidecar.

Since MTProto is mtg-served (not Xray), sniffing does not apply: hide the
Sniffing tab and the Advanced sniffing sub-editor, drop it from the
Advanced "All" JSON view, and emit empty sniffing in the wire payload,
all gated by a new canEnableSniffing predicate.
This commit is contained in:
MHSanaei
2026-06-09 12:44:04 +02:00
parent f8e89cc848
commit 6c1594693d
27 changed files with 592 additions and 55 deletions
+77 -12
View File
@@ -23,6 +23,15 @@ type Instance struct {
Listen string
Port int
Secret string
// Optional mtg tuning; each is omitted from the generated TOML when
// zero-valued so mtg falls back to its own defaults.
Debug bool
ProxyProtocolListener bool
PreferIP string
FrontingIP string
FrontingPort int
FrontingProxyProtocol bool
}
func (inst Instance) bindTo() string {
@@ -33,8 +42,19 @@ func (inst Instance) bindTo() string {
return fmt.Sprintf("%s:%d", listen, inst.Port)
}
// fingerprint changes whenever any value that ends up in the generated TOML
// changes, so ensureLocked restarts mtg when the operator edits a setting.
func (inst Instance) fingerprint() string {
return fmt.Sprintf("%s|%s", inst.bindTo(), inst.Secret)
return strings.Join([]string{
inst.bindTo(),
inst.Secret,
strconv.FormatBool(inst.Debug),
strconv.FormatBool(inst.ProxyProtocolListener),
inst.PreferIP,
inst.FrontingIP,
strconv.Itoa(inst.FrontingPort),
strconv.FormatBool(inst.FrontingProxyProtocol),
}, "|")
}
// Traffic is a per-inbound traffic delta scraped from an mtg metrics endpoint.
@@ -88,7 +108,15 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
settings = healed
}
var parsed struct {
Secret string `json:"secret"`
Secret string `json:"secret"`
Debug bool `json:"debug"`
ProxyProtocolListener bool `json:"proxyProtocolListener"`
PreferIP string `json:"preferIp"`
DomainFronting struct {
IP string `json:"ip"`
Port int `json:"port"`
ProxyProtocol bool `json:"proxyProtocol"`
} `json:"domainFronting"`
}
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
return Instance{}, false
@@ -97,11 +125,17 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
return Instance{}, false
}
return Instance{
Id: ib.Id,
Tag: ib.Tag,
Listen: ib.Listen,
Port: ib.Port,
Secret: parsed.Secret,
Id: ib.Id,
Tag: ib.Tag,
Listen: ib.Listen,
Port: ib.Port,
Secret: parsed.Secret,
Debug: parsed.Debug,
ProxyProtocolListener: parsed.ProxyProtocolListener,
PreferIP: parsed.PreferIP,
FrontingIP: parsed.DomainFronting.IP,
FrontingPort: parsed.DomainFronting.Port,
FrontingProxyProtocol: parsed.DomainFronting.ProxyProtocol,
}, true
}
@@ -143,7 +177,7 @@ func (m *Manager) ensureLocked(inst Instance) error {
return err
}
cfgPath := configPathForID(inst.Id)
if err := writeConfig(cfgPath, inst.Secret, inst.bindTo(), metricsPort); err != nil {
if err := writeConfig(cfgPath, inst, metricsPort); err != nil {
return err
}
proc := newProcess(cfgPath, fmt.Sprintf("inbound %d", inst.Id))
@@ -282,13 +316,44 @@ func freeLocalPort() (int, error) {
return l.Addr().(*net.TCPAddr).Port, nil
}
func writeConfig(path, secret, bindTo string, metricsPort int) error {
// renderConfig builds the mtg TOML for an instance. Top-level keys must precede
// any [section] header in TOML, so the layout is: required keys, then the
// optional scalar tuning, then [domain-fronting], and finally [stats.prometheus]
// — which x-ui always emits and scrapes for traffic (see scrapeTraffic).
func renderConfig(inst Instance, metricsPort int) string {
var b strings.Builder
fmt.Fprintf(&b, "secret = %q\n", inst.Secret)
fmt.Fprintf(&b, "bind-to = %q\n", inst.bindTo())
if inst.Debug {
b.WriteString("debug = true\n")
}
if inst.ProxyProtocolListener {
b.WriteString("proxy-protocol-listener = true\n")
}
if inst.PreferIP != "" {
fmt.Fprintf(&b, "prefer-ip = %q\n", inst.PreferIP)
}
if inst.FrontingIP != "" || inst.FrontingPort > 0 || inst.FrontingProxyProtocol {
b.WriteString("\n[domain-fronting]\n")
if inst.FrontingIP != "" {
fmt.Fprintf(&b, "ip = %q\n", inst.FrontingIP)
}
if inst.FrontingPort > 0 {
fmt.Fprintf(&b, "port = %d\n", inst.FrontingPort)
}
if inst.FrontingProxyProtocol {
b.WriteString("proxy-protocol = true\n")
}
}
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()
}
func writeConfig(path string, inst Instance, metricsPort int) error {
if err := os.MkdirAll(configDir(), 0o750); err != nil {
return err
}
content := fmt.Sprintf("secret = %q\nbind-to = %q\n\n[stats.prometheus]\nenabled = true\nbind-to = \"127.0.0.1:%d\"\nhttp-path = \"/metrics\"\nmetric-prefix = \"mtg\"\n",
secret, bindTo, metricsPort)
return os.WriteFile(path, []byte(content), 0o640)
return os.WriteFile(path, []byte(renderConfig(inst, metricsPort)), 0o640)
}
// scrapeTraffic reads the mtg Prometheus metrics endpoint and sums byte
+72 -1
View File
@@ -1,6 +1,7 @@
package mtproto
import (
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/database/model"
@@ -37,7 +38,9 @@ func TestInstanceFromInbound(t *testing.T) {
Listen: "0.0.0.0",
Port: 8443,
Protocol: model.MTProto,
Settings: `{"fakeTlsDomain":"example.com","secret":""}`,
Settings: `{"fakeTlsDomain":"example.com","secret":"",` +
`"debug":true,"proxyProtocolListener":true,"preferIp":"prefer-ipv4",` +
`"domainFronting":{"ip":"127.0.0.1","port":9443,"proxyProtocol":true}}`,
}
inst, ok := InstanceFromInbound(ib)
if !ok {
@@ -49,8 +52,76 @@ func TestInstanceFromInbound(t *testing.T) {
if inst.Port != 8443 || inst.Id != 3 {
t.Fatalf("bad instance %+v", inst)
}
if !inst.Debug || !inst.ProxyProtocolListener || inst.PreferIP != "prefer-ipv4" {
t.Fatalf("scalar options not parsed: %+v", inst)
}
if inst.FrontingIP != "127.0.0.1" || inst.FrontingPort != 9443 || !inst.FrontingProxyProtocol {
t.Fatalf("domain-fronting not parsed: %+v", inst)
}
if _, ok := InstanceFromInbound(&model.Inbound{Protocol: model.VLESS}); ok {
t.Fatal("non-mtproto inbound should not produce an instance")
}
}
func TestRenderConfig(t *testing.T) {
// A bare instance emits only the required keys and the prometheus block,
// with no optional keys and no [domain-fronting] section.
bare := renderConfig(Instance{Secret: "ee00", Listen: "0.0.0.0", Port: 8443}, 5000)
for _, unwanted := range []string{"debug", "proxy-protocol-listener", "prefer-ip", "[domain-fronting]"} {
if strings.Contains(bare, unwanted) {
t.Fatalf("bare config should not contain %q:\n%s", unwanted, bare)
}
}
if !strings.Contains(bare, `bind-to = "0.0.0.0:8443"`) {
t.Fatalf("missing bind-to:\n%s", bare)
}
if !strings.Contains(bare, "[stats.prometheus]") || !strings.Contains(bare, "127.0.0.1:5000") {
t.Fatalf("prometheus block must always be present:\n%s", bare)
}
// A fully configured instance emits every option and the fronting section.
full := renderConfig(Instance{
Secret: "ee11", Listen: "0.0.0.0", Port: 443,
Debug: true, ProxyProtocolListener: true, PreferIP: "only-ipv6",
FrontingIP: "127.0.0.1", FrontingPort: 9443, FrontingProxyProtocol: true,
}, 6000)
for _, want := range []string{
"debug = true\n",
"proxy-protocol-listener = true\n",
`prefer-ip = "only-ipv6"`,
"[domain-fronting]",
`ip = "127.0.0.1"`,
"port = 9443",
"proxy-protocol = true\n",
} {
if !strings.Contains(full, want) {
t.Fatalf("full config missing %q:\n%s", want, full)
}
}
// TOML requires top-level keys before any [section] header.
if strings.Index(full, "prefer-ip") > strings.Index(full, "[domain-fronting]") {
t.Fatalf("top-level keys must precede the [domain-fronting] section:\n%s", full)
}
if strings.LastIndex(full, "[domain-fronting]") > strings.Index(full, "[stats.prometheus]") {
t.Fatalf("[domain-fronting] must precede [stats.prometheus]:\n%s", full)
}
}
func TestFingerprintReactsToOptions(t *testing.T) {
base := Instance{Secret: "ee", Listen: "0.0.0.0", Port: 443}
for name, mutate := range map[string]func(*Instance){
"debug": func(i *Instance) { i.Debug = true },
"listener": func(i *Instance) { i.ProxyProtocolListener = true },
"preferIp": func(i *Instance) { i.PreferIP = "only-ipv4" },
"frontingIP": func(i *Instance) { i.FrontingIP = "127.0.0.1" },
"frontingPort": func(i *Instance) { i.FrontingPort = 9443 },
"frontingProxy": func(i *Instance) { i.FrontingProxyProtocol = true },
} {
changed := base
mutate(&changed)
if base.fingerprint() == changed.fingerprint() {
t.Fatalf("fingerprint must change when %s changes", name)
}
}
}