| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580 |
- package mtproto
- import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "net"
- "net/http"
- "os"
- "slices"
- "strconv"
- "strings"
- "sync"
- "time"
- "github.com/mhsanaei/3x-ui/v3/internal/database/model"
- "github.com/mhsanaei/3x-ui/v3/internal/logger"
- )
- // SecretEntry is one named FakeTLS secret served by an mtg-multi process. Name is
- // the client email, used both as the [secrets] key and as the per-user key in the
- // /stats API so traffic can be attributed back to the client.
- type SecretEntry struct {
- Name string
- Secret string
- }
- // Instance is the desired runtime configuration of one mtproto inbound. A single
- // mtg-multi process serves every active client's secret through the [secrets]
- // section, so one inbound maps to one process with many named secrets.
- type Instance struct {
- Id int
- Tag string
- Listen string
- Port int
- Secrets []SecretEntry
- // 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
- // ThrottleMaxConnections caps concurrent connections across all users with a
- // fair-share algorithm; zero disables throttling.
- ThrottleMaxConnections int
- // AdTag is a 32-hex Telegram advertising tag; when set, mtg routes clients
- // through Telegram middle proxies so a sponsored channel shows in their chat
- // list. It is part of the reloadable secret config, so a change is applied
- // via /reload without dropping connections. PublicIPv4/PublicIPv6 pin the
- // proxy's reachable address the middle proxy needs; they are omitted when
- // empty so mtg auto-detects, and a change forces a restart.
- AdTag string
- PublicIPv4 string
- PublicIPv6 string
- // 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 {
- listen := inst.Listen
- if listen == "" {
- listen = "0.0.0.0"
- }
- return fmt.Sprintf("%s:%d", listen, inst.Port)
- }
- // structuralFingerprint changes whenever a value outside the [secrets] section
- // of the generated TOML changes. Such a change can only be applied by
- // restarting mtg, unlike a secrets-only change, which a reload-capable mtg can
- // absorb in place.
- func (inst Instance) structuralFingerprint() string {
- parts := []string{
- inst.bindTo(),
- strconv.FormatBool(inst.Debug),
- strconv.FormatBool(inst.ProxyProtocolListener),
- inst.PreferIP,
- inst.FrontingIP,
- strconv.Itoa(inst.FrontingPort),
- strconv.FormatBool(inst.FrontingProxyProtocol),
- strconv.Itoa(inst.ThrottleMaxConnections),
- strconv.FormatBool(inst.RouteThroughXray),
- strconv.Itoa(inst.XrayRoutePort),
- inst.PublicIPv4,
- inst.PublicIPv6,
- }
- return strings.Join(parts, "|")
- }
- // secretsFingerprint identifies the reloadable secret config regardless of
- // client order, so a reordered clients array in the stored settings does not
- // read as a change. It moves whenever a client is added, removed, disabled, or
- // re-keyed, or the advertising tag changes — all of which mtg applies through
- // /reload without dropping connections.
- func (inst Instance) secretsFingerprint() string {
- pairs := make([]string, 0, len(inst.Secrets))
- for _, e := range inst.Secrets {
- pairs = append(pairs, e.Name+"="+e.Secret)
- }
- slices.Sort(pairs)
- return "adtag=" + inst.AdTag + "|" + strings.Join(pairs, "|")
- }
- // Traffic is a per-client traffic delta scraped from an mtg /stats endpoint. Tag
- // is the owning inbound's tag and Email is the client the bytes belong to.
- type Traffic struct {
- Tag string
- Email string
- Up int64
- Down int64
- }
- type clientCounters struct {
- up int64
- down int64
- }
- type managed struct {
- proc *Process
- tag string
- structuralFP string
- secretsFP string
- apiPort int
- last map[string]clientCounters
- }
- // Manager owns the set of running mtg processes keyed by inbound id.
- type Manager struct {
- mu sync.Mutex
- procs map[int]*managed
- // swept records that the one-time startup cleanup of orphaned mtg
- // processes (survivors of a previous x-ui run) has already run.
- swept bool
- }
- var (
- managerOnce sync.Once
- manager *Manager
- )
- // GetManager returns the process-wide mtg manager singleton.
- func GetManager() *Manager {
- managerOnce.Do(func() {
- manager = &Manager{procs: map[int]*managed{}}
- })
- return manager
- }
- // InstanceFromInbound derives a desired Instance from an mtproto inbound,
- // building one named secret per active client. Secrets are healed on save (see
- // normalizeMtprotoSecret) and by the migration, so they are read as-is here to
- // keep the fingerprint stable across reconciles. Returns false when the inbound
- // is not a usable mtproto inbound or has no active client secret to serve.
- func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
- if ib == nil || ib.Protocol != model.MTProto {
- return Instance{}, false
- }
- settings := ib.Settings
- var parsed struct {
- ProxyProtocolListener bool `json:"proxyProtocolListener"`
- Debug bool `json:"debug"`
- DomainFronting struct {
- IP string `json:"ip"`
- Port int `json:"port"`
- ProxyProtocol bool `json:"proxyProtocol"`
- } `json:"domainFronting"`
- PreferIP string `json:"preferIp"`
- ThrottleMaxConnections int `json:"throttleMaxConnections"`
- RouteThroughXray bool `json:"routeThroughXray"`
- RouteXrayPort int `json:"routeXrayPort"`
- AdTag string `json:"adTag"`
- PublicIPv4 string `json:"publicIpv4"`
- PublicIPv6 string `json:"publicIpv6"`
- Clients []struct {
- Email string `json:"email"`
- Secret string `json:"secret"`
- Enable bool `json:"enable"`
- } `json:"clients"`
- }
- if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
- return Instance{}, false
- }
- secrets := make([]SecretEntry, 0, len(parsed.Clients))
- for _, c := range parsed.Clients {
- if !c.Enable || c.Secret == "" || c.Email == "" {
- continue
- }
- secrets = append(secrets, SecretEntry{Name: c.Email, Secret: c.Secret})
- }
- if len(secrets) == 0 {
- return Instance{}, false
- }
- return Instance{
- Id: ib.Id,
- Tag: ib.Tag,
- Listen: ib.Listen,
- Port: ib.Port,
- Secrets: secrets,
- Debug: parsed.Debug,
- ProxyProtocolListener: parsed.ProxyProtocolListener,
- PreferIP: parsed.PreferIP,
- FrontingIP: parsed.DomainFronting.IP,
- FrontingPort: parsed.DomainFronting.Port,
- FrontingProxyProtocol: parsed.DomainFronting.ProxyProtocol,
- ThrottleMaxConnections: parsed.ThrottleMaxConnections,
- RouteThroughXray: parsed.RouteThroughXray,
- XrayRoutePort: parsed.RouteXrayPort,
- AdTag: strings.TrimSpace(parsed.AdTag),
- PublicIPv4: strings.TrimSpace(parsed.PublicIPv4),
- PublicIPv6: strings.TrimSpace(parsed.PublicIPv6),
- }, true
- }
- // Ensure starts the mtg process for an instance, or restarts it when its
- // configuration changed. A no-op when the desired process is already running.
- func (m *Manager) Ensure(inst Instance) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- m.sweepOrphansLocked()
- return m.ensureLocked(inst)
- }
- // sweepOrphansLocked kills mtg processes left running by a previous x-ui run,
- // exactly once per process lifetime and before any of our own mtg are started.
- // Because x-ui owns every mtg process, anything alive at this point is an orphan
- // that would otherwise keep holding an inbound port with a stale secret.
- func (m *Manager) sweepOrphansLocked() {
- if m.swept {
- return
- }
- m.swept = true
- if n := killStrayMtgProcesses(GetBinaryPath()); n > 0 {
- logger.Warningf("mtproto: terminated %d orphaned mtg process(es) from a previous run", n)
- }
- }
- // ensureAction is what ensureLocked must do to move a running mtg process to a
- // desired instance: leave it alone, hot-reload only its secrets, or fully
- // restart it.
- type ensureAction int
- const (
- ensureNoop ensureAction = iota
- ensureReload
- ensureRestart
- )
- // ensureActionFor decides how to apply a desired instance to the currently
- // managed process. A structural change (or a dead process) forces a restart; a
- // secrets-only change is a candidate for an in-place reload; identical
- // fingerprints on a live process need nothing.
- func ensureActionFor(running bool, curStructFP, curSecretsFP, newStructFP, newSecretsFP string) ensureAction {
- if !running || curStructFP != newStructFP {
- return ensureRestart
- }
- if curSecretsFP != newSecretsFP {
- return ensureReload
- }
- return ensureNoop
- }
- func (m *Manager) ensureLocked(inst Instance) error {
- structFP := inst.structuralFingerprint()
- secFP := inst.secretsFingerprint()
- if cur, ok := m.procs[inst.Id]; ok {
- switch ensureActionFor(cur.proc.IsRunning(), cur.structuralFP, cur.secretsFP, structFP, secFP) {
- case ensureNoop:
- cur.tag = inst.Tag
- return nil
- case ensureReload:
- if err := writeConfig(configPathForID(inst.Id), inst, cur.apiPort); err != nil {
- return err
- }
- if applySecrets(cur.apiPort, inst) {
- cur.tag = inst.Tag
- cur.secretsFP = secFP
- logger.Infof("mtproto: applied secret update to inbound %d in place", inst.Id)
- return nil
- }
- logger.Warningf("mtproto: live secret update unavailable for inbound %d, restarting", inst.Id)
- fallthrough
- case ensureRestart:
- _ = cur.proc.Stop()
- delete(m.procs, inst.Id)
- }
- }
- apiPort, err := FreeLocalPort()
- if err != nil {
- return err
- }
- cfgPath := configPathForID(inst.Id)
- if err := writeConfig(cfgPath, inst, apiPort); err != nil {
- return err
- }
- proc := newProcess(cfgPath, fmt.Sprintf("inbound %d", inst.Id))
- if err := proc.Start(); err != nil {
- return err
- }
- m.procs[inst.Id] = &managed{
- proc: proc,
- tag: inst.Tag,
- structuralFP: structFP,
- secretsFP: secFP,
- apiPort: apiPort,
- last: map[string]clientCounters{},
- }
- logger.Infof("mtproto: started mtg for inbound %d on %s", inst.Id, inst.bindTo())
- return nil
- }
- // Remove stops and forgets the mtg process for an inbound id.
- func (m *Manager) Remove(id int) {
- m.mu.Lock()
- defer m.mu.Unlock()
- if cur, ok := m.procs[id]; ok {
- _ = cur.proc.Stop()
- delete(m.procs, id)
- _ = os.Remove(configPathForID(id))
- logger.Infof("mtproto: stopped mtg for inbound %d", id)
- }
- }
- // Reconcile drives the running set toward the desired instances: it stops
- // processes that are no longer wanted and (re)starts the rest. Used at boot
- // and periodically to recover from crashes.
- func (m *Manager) Reconcile(desired []Instance) {
- m.mu.Lock()
- defer m.mu.Unlock()
- m.sweepOrphansLocked()
- want := make(map[int]struct{}, len(desired))
- for _, inst := range desired {
- want[inst.Id] = struct{}{}
- }
- for id, cur := range m.procs {
- if _, ok := want[id]; !ok {
- _ = cur.proc.Stop()
- delete(m.procs, id)
- _ = os.Remove(configPathForID(id))
- }
- }
- for _, inst := range desired {
- if err := m.ensureLocked(inst); err != nil {
- logger.Warningf("mtproto: reconcile failed for inbound %d: %v", inst.Id, err)
- }
- }
- }
- // StopAll stops every managed mtg process. Called on panel shutdown.
- func (m *Manager) StopAll() {
- m.mu.Lock()
- defer m.mu.Unlock()
- for id, cur := range m.procs {
- _ = cur.proc.Stop()
- _ = os.Remove(configPathForID(id))
- delete(m.procs, id)
- }
- }
- // CollectTraffic scrapes each running mtg /stats endpoint and returns the
- // per-client byte deltas since the previous scrape, plus the emails of clients
- // with at least one live connection.
- func (m *Manager) CollectTraffic() ([]Traffic, []string) {
- type snap struct {
- id int
- apiPort int
- tag string
- last map[string]clientCounters
- }
- m.mu.Lock()
- snaps := make([]snap, 0, len(m.procs))
- for id, cur := range m.procs {
- if cur.proc == nil || !cur.proc.IsRunning() {
- continue
- }
- lastCopy := make(map[string]clientCounters, len(cur.last))
- for k, v := range cur.last {
- lastCopy[k] = v
- }
- snaps = append(snaps, snap{id: id, apiPort: cur.apiPort, tag: cur.tag, last: lastCopy})
- }
- m.mu.Unlock()
- var out []Traffic
- var online []string
- for _, s := range snaps {
- users, ok := scrapeStats(s.apiPort)
- if !ok {
- continue
- }
- newLast := make(map[string]clientCounters, len(users))
- for email, u := range users {
- up := u.BytesIn
- down := u.BytesOut
- newLast[email] = clientCounters{up: up, down: down}
- if u.Connections > 0 {
- online = append(online, email)
- }
- prev, had := s.last[email]
- if !had {
- continue
- }
- du := up - prev.up
- dd := down - prev.down
- if du < 0 {
- du = 0
- }
- if dd < 0 {
- dd = 0
- }
- if du > 0 || dd > 0 {
- out = append(out, Traffic{Tag: s.tag, Email: email, Up: du, Down: dd})
- }
- }
- m.mu.Lock()
- if cur, ok := m.procs[s.id]; ok {
- cur.last = newLast
- }
- m.mu.Unlock()
- }
- return out, online
- }
- // FreeLocalPort asks the OS for an unused loopback TCP port. It is used both
- // for mtg's /stats API endpoint and to allocate the per-inbound SOCKS egress
- // bridge port persisted into mtproto inbound settings.
- func FreeLocalPort() (int, error) {
- l, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", "127.0.0.1:0")
- if err != nil {
- return 0, err
- }
- defer l.Close()
- return l.Addr().(*net.TCPAddr).Port, nil
- }
- // renderConfig builds the mtg-multi TOML for an instance. Top-level keys must
- // precede any [section] header in TOML, and [secrets] must be the final section
- // so trailing keys are not swallowed by another table. The layout is therefore:
- // top-level scalars (incl. api-bind-to), then [domain-fronting], [network] and
- // [throttle], and finally [secrets] with one named secret per active client.
- func renderConfig(inst Instance, apiPort int) string {
- var b strings.Builder
- 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)
- }
- fmt.Fprintf(&b, "api-bind-to = \"127.0.0.1:%d\"\n", apiPort)
- if inst.AdTag != "" {
- fmt.Fprintf(&b, "ad-tag = %q\n", inst.AdTag)
- }
- if inst.PublicIPv4 != "" {
- fmt.Fprintf(&b, "public-ipv4 = %q\n", inst.PublicIPv4)
- }
- if inst.PublicIPv6 != "" {
- fmt.Fprintf(&b, "public-ipv6 = %q\n", inst.PublicIPv6)
- }
- if inst.FrontingIP != "" || inst.FrontingPort > 0 || inst.FrontingProxyProtocol {
- b.WriteString("\n[domain-fronting]\n")
- if inst.FrontingIP != "" {
- fmt.Fprintf(&b, "host = %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")
- }
- }
- // 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)
- }
- if inst.ThrottleMaxConnections > 0 {
- fmt.Fprintf(&b, "\n[throttle]\nmax-connections = %d\n", inst.ThrottleMaxConnections)
- }
- b.WriteString("\n[secrets]\n")
- for _, e := range inst.Secrets {
- fmt.Fprintf(&b, "%q = %q\n", e.Name, e.Secret)
- }
- return b.String()
- }
- func writeConfig(path string, inst Instance, apiPort int) error {
- if err := os.MkdirAll(configDir(), 0o750); err != nil {
- return err
- }
- return os.WriteFile(path, []byte(renderConfig(inst, apiPort)), 0o640)
- }
- // statsUser is one entry of the mtg-multi /stats users map. bytes_in is traffic
- // the client sent to the proxy (upload) and bytes_out is what the proxy returned
- // (download).
- type statsUser struct {
- Connections int64 `json:"connections"`
- BytesIn int64 `json:"bytes_in"`
- BytesOut int64 `json:"bytes_out"`
- }
- type secretPutEntry struct {
- Secret string `json:"secret"`
- }
- type secretsPutBody struct {
- Secrets map[string]secretPutEntry `json:"secrets"`
- AdTag string `json:"ad_tag,omitempty"`
- }
- func secretsPayload(inst Instance) secretsPutBody {
- secrets := make(map[string]secretPutEntry, len(inst.Secrets))
- for _, e := range inst.Secrets {
- secrets[e.Name] = secretPutEntry{Secret: e.Secret}
- }
- return secretsPutBody{Secrets: secrets, AdTag: inst.AdTag}
- }
- // applySecrets pushes the desired secret set and advertising tag to a running
- // mtg-multi through its management API (PUT /secrets on the same loopback port
- // that serves /stats), so a client add, removal, re-key, or ad-tag change is
- // applied in place. mtg keeps every connection whose secret is unchanged and
- // closes only the removed or re-keyed ones. It returns true only on a 200: an
- // older binary without the endpoint (404), a refused connection, or any other
- // status yields false, so the caller falls back to a full restart.
- func applySecrets(port int, inst Instance) bool {
- body, err := json.Marshal(secretsPayload(inst))
- if err != nil {
- return false
- }
- client := http.Client{Timeout: 3 * time.Second}
- req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("http://127.0.0.1:%d/secrets", port), bytes.NewReader(body))
- if err != nil {
- return false
- }
- req.Header.Set("Content-Type", "application/json")
- resp, err := client.Do(req)
- if err != nil {
- return false
- }
- defer resp.Body.Close()
- return resp.StatusCode == http.StatusOK
- }
- // scrapeStats reads the mtg-multi /stats JSON API and returns the per-user
- // cumulative counters. Best-effort: an unreachable endpoint or unparseable body
- // yields ok=false.
- func scrapeStats(port int) (map[string]statsUser, bool) {
- client := http.Client{Timeout: 3 * time.Second}
- req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/stats", port), nil)
- if err != nil {
- return nil, false
- }
- resp, err := client.Do(req)
- if err != nil {
- return nil, false
- }
- defer resp.Body.Close()
- var parsed struct {
- Users map[string]statsUser `json:"users"`
- }
- if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
- return nil, false
- }
- return parsed.Users, true
- }
|