mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-30 01:24:20 +00:00
f8e89cc848
* fix(logs): render journalctl output in the SysLog viewer The log viewer's parseLogLine only understood the app-log format (2006/01/02 15:04:05 LEVEL - body). With SysLog ticked the backend returns journalctl lines (Mon DD HH:MM:SS host ident[pid]: LEVEL - body), so the parser mistook the journal time for the level and dropped the body, leaving only timestamps. Detect and strip the journald prefix, keep the journal timestamp as the stamp, then parse the real level and body from the remainder. * feat(mtproto): surface mtg output and add status reporting mtg's stdout/stderr was captured by a writer that kept only the last line and showed it nowhere, so the reason a proxy could not reach Telegram was invisible. Stream mtg output line-by-line into the x-ui log, tagged per inbound, so it appears in the panel log viewer and journald. Also fix mangled log lines: logger.Info uses fmt.Sprint, which drops the space between adjacent string operands, producing output like 'inbound3on0.0.0.0:8443'. Switch the affected mtproto calls to the formatted (*f) variants. Add show_mtproto_status to x-ui.sh so 'x-ui status' reports each mtproto inbound's mtg process state and bind address. * fix(logs): parse all journalctl message shapes in SysLog viewer Real journalctl output mixes four message shapes after the 'Mon DD HH:MM:SS host ident[pid]:' prefix: go-logging 'LEVEL - msg' (x-ui/xray), Go std-log with an embedded date (net/http, runtime), telego's '[timestamp] LEVEL msg', and systemd lines. The viewer only understood the first, so std-log and telego lines — which never contain ' - ' — collapsed to a bare timestamp (e.g. the 8s telego 409 spam). Extract the parser into a pure, testable module and teach it the other shapes: strip the redundant Go std-log date, lift the level out of telego brackets, and always keep the message body. Add a unit test covering each shape with real captured lines. * fix(mtproto): reap orphaned mtg sidecars so a stale one can't break new clients On Linux x-ui does not kill its mtg children when it dies (no kill-on-exit, unlike the Windows job object). After a crash, OOM, kill -9, or update, a stale mtg keeps holding the inbound port with an OLD secret, so new clients fail the FakeTLS handshake and get silently domain-fronted to the fakeTLS domain instead of proxied to Telegram (a few MB of traffic, never connects). Sweep orphans at startup: on the first reconcile, before x-ui starts any of its own mtg, scan /proc and SIGKILL any process whose executable is our mtg-<goos>-<goarch> binary. x-ui is the sole owner of mtg, so anything alive then is an orphan. Runs once per process (swept guard), survives the binary-deleted-during-update case via /proc/<pid>/cmdline, and is a no-op on Windows (job object) and other platforms. Also clear stray mtg in update.sh/install.sh after stopping x-ui, anchored to the 'mtg-linux-<arch> run ' invocation so the pattern can't match unrelated command lines (e.g. x-ui.sh's own 'grep mtg-linux'). * fix(logs): drop dead body initializer flagged by eslint no-useless-assignment * fix(mtproto): drop remark fragment from tg://proxy export link The mtproto export link appended the inbound remark as a URL fragment (tg://proxy?server=...&port=...&secret=...#remark). Telegram Desktop rejects a proxy deep link with a trailing fragment as 'This proxy link is invalid', breaking one-click import, and a remark is meaningless for proxy links across clients. Stop adding it in both the panel link (genMtprotoLink) and the subscription service. Fixes #5105. * fix(x-ui.sh): remove unused check_mtproto_status helper show_mtproto_status does its own process check, so check_mtproto_status was dead code. Drop it (per Copilot review on #5107).
236 lines
5.5 KiB
Go
236 lines
5.5 KiB
Go
// Package mtproto manages mtg (github.com/9seconds/mtg) sidecar processes that
|
|
// serve MTProto FakeTLS proxies. Xray-core has no mtproto protocol, so mtproto
|
|
// inbounds are run as standalone mtg processes — one process per inbound —
|
|
// entirely outside the Xray config and lifecycle.
|
|
package mtproto
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/config"
|
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
|
)
|
|
|
|
// GetBinaryName returns the mtg binary filename for the current OS and arch,
|
|
// matching the naming scheme used for the Xray binary. On Windows the ".exe"
|
|
// extension is appended so a natural "mtg-windows-amd64.exe" is found.
|
|
func GetBinaryName() string {
|
|
name := fmt.Sprintf("mtg-%s-%s", runtime.GOOS, runtime.GOARCH)
|
|
if runtime.GOOS == "windows" {
|
|
name += ".exe"
|
|
}
|
|
return name
|
|
}
|
|
|
|
// GetBinaryPath returns the full path to the mtg binary, alongside the Xray binary.
|
|
func GetBinaryPath() string {
|
|
return config.GetBinFolderPath() + "/" + GetBinaryName()
|
|
}
|
|
|
|
func configDir() string {
|
|
return config.GetBinFolderPath() + "/mtproto"
|
|
}
|
|
|
|
func configPathForID(id int) string {
|
|
return fmt.Sprintf("%s/mtg-%d.toml", configDir(), id)
|
|
}
|
|
|
|
var (
|
|
gracefulStopTimeout = 5 * time.Second
|
|
forceStopTimeout = 2 * time.Second
|
|
)
|
|
|
|
// procLogWriter consumes the mtg child process's stdout/stderr. It splits the
|
|
// stream into lines, forwards each one to the x-ui log — so mtg's own messages,
|
|
// including why it cannot reach Telegram, become visible in the panel log viewer
|
|
// and journald — and remembers the most recent line for GetResult.
|
|
type procLogWriter struct {
|
|
mu sync.Mutex
|
|
label string
|
|
buf string
|
|
lastLine string
|
|
}
|
|
|
|
func (w *procLogWriter) Write(p []byte) (int, error) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
w.buf += string(p)
|
|
for {
|
|
i := strings.IndexByte(w.buf, '\n')
|
|
if i < 0 {
|
|
break
|
|
}
|
|
line := w.buf[:i]
|
|
w.buf = w.buf[i+1:]
|
|
w.emitLocked(line)
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
// Flush emits any buffered partial line; called once the process exits so a
|
|
// final un-terminated error line is not lost.
|
|
func (w *procLogWriter) Flush() {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
if w.buf != "" {
|
|
line := w.buf
|
|
w.buf = ""
|
|
w.emitLocked(line)
|
|
}
|
|
}
|
|
|
|
func (w *procLogWriter) emitLocked(line string) {
|
|
trimmed := strings.TrimSpace(strings.TrimRight(line, "\r"))
|
|
if trimmed == "" {
|
|
return
|
|
}
|
|
w.lastLine = trimmed
|
|
logger.Infof("mtproto: mtg %s | %s", w.label, trimmed)
|
|
}
|
|
|
|
func (w *procLogWriter) LastLine() string {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
return w.lastLine
|
|
}
|
|
|
|
// Process wraps a single mtg process invocation for one mtproto inbound.
|
|
type Process struct {
|
|
cmd *exec.Cmd
|
|
done chan struct{}
|
|
configPath string
|
|
logWriter *procLogWriter
|
|
exitErr error
|
|
intentionalStop atomic.Bool
|
|
}
|
|
|
|
func newProcess(configPath, label string) *Process {
|
|
return &Process{
|
|
configPath: configPath,
|
|
logWriter: &procLogWriter{label: label},
|
|
}
|
|
}
|
|
|
|
// IsRunning reports whether the mtg process is currently running.
|
|
func (p *Process) IsRunning() bool {
|
|
if p.cmd == nil || p.cmd.Process == nil {
|
|
return false
|
|
}
|
|
if p.done != nil {
|
|
select {
|
|
case <-p.done:
|
|
return false
|
|
default:
|
|
}
|
|
}
|
|
if p.cmd.ProcessState == nil {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// GetResult returns the last log line or the exit error from the mtg process.
|
|
func (p *Process) GetResult() string {
|
|
if line := p.logWriter.LastLine(); line != "" {
|
|
return line
|
|
}
|
|
if p.exitErr != nil {
|
|
return p.exitErr.Error()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Start launches the mtg process against its generated config file.
|
|
func (p *Process) Start() error {
|
|
if p.IsRunning() {
|
|
return errors.New("mtg is already running")
|
|
}
|
|
cmd := exec.Command(GetBinaryPath(), "run", p.configPath)
|
|
cmd.Stdout = p.logWriter
|
|
cmd.Stderr = p.logWriter
|
|
p.cmd = cmd
|
|
p.done = make(chan struct{})
|
|
p.exitErr = nil
|
|
p.intentionalStop.Store(false)
|
|
if err := cmd.Start(); err != nil {
|
|
close(p.done)
|
|
p.cmd = nil
|
|
return err
|
|
}
|
|
attachChildLifetime(cmd)
|
|
go p.wait(cmd)
|
|
return nil
|
|
}
|
|
|
|
func (p *Process) wait(cmd *exec.Cmd) {
|
|
defer close(p.done)
|
|
err := cmd.Wait()
|
|
p.logWriter.Flush()
|
|
if err == nil || p.intentionalStop.Load() {
|
|
return
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
if strings.Contains(strings.ToLower(err.Error()), "exit status 1") {
|
|
p.exitErr = err
|
|
return
|
|
}
|
|
}
|
|
logger.Errorf("mtproto: mtg process exited: %v", err)
|
|
p.exitErr = err
|
|
}
|
|
|
|
// Stop terminates the running mtg process gracefully, falling back to a kill.
|
|
func (p *Process) Stop() error {
|
|
if !p.IsRunning() {
|
|
return errors.New("mtg is not running")
|
|
}
|
|
p.intentionalStop.Store(true)
|
|
|
|
if runtime.GOOS == "windows" {
|
|
if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
|
return err
|
|
}
|
|
return p.waitForExit(forceStopTimeout)
|
|
}
|
|
|
|
if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
|
if errors.Is(err, os.ErrProcessDone) {
|
|
return p.waitForExit(forceStopTimeout)
|
|
}
|
|
return err
|
|
}
|
|
|
|
if err := p.waitForExit(gracefulStopTimeout); err == nil {
|
|
return nil
|
|
}
|
|
|
|
logger.Warning("mtproto: mtg did not stop after SIGTERM, killing process")
|
|
if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
|
return err
|
|
}
|
|
return p.waitForExit(forceStopTimeout)
|
|
}
|
|
|
|
func (p *Process) waitForExit(timeout time.Duration) error {
|
|
if p.done == nil {
|
|
return nil
|
|
}
|
|
timer := time.NewTimer(timeout)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-p.done:
|
|
return nil
|
|
case <-timer.C:
|
|
return fmt.Errorf("timed out waiting for mtg process to stop after %s", timeout)
|
|
}
|
|
}
|