Forráskód Böngészése

fix(mtproto): reap orphaned mtg, fix SysLog viewer, mtg log visibility, export remark (#5105) (#5107)

* 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).
Sanaei 10 órája
szülő
commit
02edec359a

+ 2 - 5
frontend/src/lib/xray/inbound-link.ts

@@ -684,13 +684,11 @@ export interface GenMtprotoLinkInput {
   inbound: Inbound;
   address: string;
   port?: number;
-  remark?: string;
 }
 
 // Builds a Telegram proxy deep link for an mtproto inbound:
-// tg://proxy?server=<addr>&port=<port>&secret=<ee FakeTLS secret>.
 export function genMtprotoLink(input: GenMtprotoLinkInput): string {
-  const { inbound, address, port = inbound.port, remark = '' } = input;
+  const { inbound, address, port = inbound.port } = input;
   if (inbound.protocol !== 'mtproto') return '';
   const secret = inbound.settings.secret ?? '';
   if (secret.length === 0) return '';
@@ -698,7 +696,6 @@ export function genMtprotoLink(input: GenMtprotoLinkInput): string {
   url.searchParams.set('server', address);
   url.searchParams.set('port', String(port));
   url.searchParams.set('secret', secret);
-  url.hash = encodeURIComponent(remark);
   return url.toString();
 }
 
@@ -890,7 +887,7 @@ export function genLink(input: GenLinkInput): string {
         externalProxy,
       });
     case 'mtproto':
-      return genMtprotoLink({ inbound, address, port, remark });
+      return genMtprotoLink({ inbound, address, port });
     default:
       return '';
   }

+ 1 - 44
frontend/src/pages/index/LogModal.tsx

@@ -5,6 +5,7 @@ import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
 
 import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
+import { parseLogLine } from './logParse';
 import './LogModal.css';
 
 interface LogModalProps {
@@ -12,50 +13,6 @@ interface LogModalProps {
   onClose: () => void;
 }
 
-interface ParsedLog {
-  date: string;
-  time: string;
-  stamp: string;
-  levelText: string;
-  levelClass: string;
-  service: string;
-  body: string;
-}
-
-const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'];
-const LEVEL_CLASSES = ['level-debug', 'level-info', 'level-notice', 'level-warning', 'level-error'];
-
-function parseLogLine(line: string): ParsedLog {
-  const [head, ...rest] = (line || '').split(' - ');
-  const message = rest.join(' - ');
-  const parts = head.split(' ');
-
-  let date = '';
-  let time = '';
-  let levelText: string;
-  if (parts.length >= 3) {
-    [date, time, levelText] = parts;
-  } else {
-    levelText = head;
-  }
-
-  const li = LEVELS.indexOf(levelText);
-  const levelClass = li >= 0 ? LEVEL_CLASSES[li] : 'level-unknown';
-
-  let service = '';
-  let body = message || '';
-  if (body.startsWith('XRAY:')) {
-    service = 'XRAY:';
-    body = body.slice('XRAY:'.length).trimStart();
-  } else if (body) {
-    service = 'X-UI:';
-  }
-
-  const stamp = [date, time].filter(Boolean).join(' ');
-
-  return { date, time, stamp, levelText, levelClass, service, body };
-}
-
 export default function LogModal({ open, onClose }: LogModalProps) {
   const { t } = useTranslation();
   const { isMobile } = useMediaQuery();

+ 113 - 0
frontend/src/pages/index/logParse.ts

@@ -0,0 +1,113 @@
+// Parser for the panel log viewer. Logs reach the UI in two shapes:
+//
+//  - App log (SysLog off): the in-memory buffer, formatted as
+//      "2006/01/02 15:04:05 LEVEL - message"
+//  - SysLog (journalctl -o short): every entry is prefixed with
+//      "Mon DD HH:MM:SS host ident[pid]: " before the real message, and the
+//    message itself is one of several shapes depending on which subsystem
+//    emitted it:
+//      "INFO - mtproto: ..."                  go-logging (x-ui + xray)
+//      "2026/06/08 19:22:22 http: ..."        Go std log (net/http, runtime)
+//      "[Mon Jun  8 23:56:52 UTC 2026] ERROR ..."  telego bot
+//      "Stopping x-ui.service - ..."          systemd
+//
+// parseLogLine normalises all of these into a stamp + level + service + body so
+// the viewer renders a readable line instead of a bare timestamp.
+
+export interface ParsedLog {
+  date: string;
+  time: string;
+  stamp: string;
+  levelText: string;
+  levelClass: string;
+  service: string;
+  body: string;
+}
+
+export const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'];
+export const LEVEL_CLASSES = [
+  'level-debug',
+  'level-info',
+  'level-notice',
+  'level-warning',
+  'level-error',
+];
+
+// "Mon DD HH:MM:SS host ident[pid]: <message>" — captures the journal date,
+// time, and the message that follows the syslog identifier.
+const SYSLOG_PREFIX = /^([A-Za-z]{3}\s+\d{1,2})\s+(\d{2}:\d{2}:\d{2})\s+\S+\s+\S+?:\s+(.*)$/;
+// Redundant Go std-log date prefix ("2006/01/02 15:04:05 ") to strip — the
+// journal already carries the timestamp.
+const GO_LOG_DATE = /^\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2}\s+/;
+// telego's own line prefix: "[Mon Jan _2 15:04:05 MST 2006] LEVEL rest".
+const TELEGO = /^\[[^\]]+\]\s+([A-Z]+)\s+(.*)$/;
+
+// splitLevelDash pulls a leading "LEVEL - " off a message, returning the level
+// and the remainder. Returns null when the message does not start with a level.
+function splitLevelDash(message: string): { level: string; rest: string } | null {
+  const dash = message.indexOf(' - ');
+  if (dash < 0) return null;
+  const level = message.slice(0, dash).trim();
+  if (LEVELS.indexOf(level) < 0) return null;
+  return { level, rest: message.slice(dash + 3) };
+}
+
+export function parseLogLine(line: string): ParsedLog {
+  const raw = (line || '').trim();
+
+  let date = '';
+  let time = '';
+  let levelText = '';
+  let body: string;
+
+  const sys = raw.match(SYSLOG_PREFIX);
+  if (sys) {
+    date = sys[1];
+    time = sys[2];
+    let message = sys[3];
+
+    const ld = splitLevelDash(message);
+    if (ld) {
+      // go-logging: "LEVEL - message"
+      levelText = ld.level;
+      body = ld.rest;
+    } else {
+      // Strip the redundant Go std-log date, then try to lift a level out of a
+      // telego "[timestamp] LEVEL ..." line; otherwise keep the message as-is.
+      message = message.replace(GO_LOG_DATE, '');
+      const tg = message.match(TELEGO);
+      if (tg && LEVELS.indexOf(tg[1]) >= 0) {
+        levelText = tg[1];
+        body = tg[2];
+      } else {
+        body = message;
+      }
+    }
+  } else {
+    // App-log format: "2006/01/02 15:04:05 LEVEL - body"
+    const [head, ...rest] = raw.split(' - ');
+    const message = rest.join(' - ');
+    const parts = head.split(' ');
+    if (parts.length >= 3) {
+      [date, time, levelText] = parts;
+    } else {
+      levelText = head;
+    }
+    body = message || '';
+  }
+
+  const li = LEVELS.indexOf(levelText);
+  const levelClass = li >= 0 ? LEVEL_CLASSES[li] : 'level-unknown';
+
+  let service = '';
+  if (body.startsWith('XRAY:')) {
+    service = 'XRAY:';
+    body = body.slice('XRAY:'.length).trimStart();
+  } else if (body) {
+    service = 'X-UI:';
+  }
+
+  const stamp = [date, time].filter(Boolean).join(' ');
+
+  return { date, time, stamp, levelText, levelClass, service, body };
+}

+ 80 - 0
frontend/src/test/log-parse.test.ts

@@ -0,0 +1,80 @@
+import { describe, it, expect } from 'vitest';
+
+import { parseLogLine } from '@/pages/index/logParse';
+
+// Fixtures are real lines captured from `journalctl -u x-ui` on a production
+// host (the SysLog view) plus the in-memory app-log format. Each journald entry
+// carries a "Mon DD HH:MM:SS host ident[pid]: " prefix that the viewer used to
+// mistake for the level, leaving only a bare timestamp on screen.
+describe('parseLogLine — SysLog (journalctl) formats', () => {
+  it('x-ui go-logging line: keeps level, strips prefix, tags X-UI', () => {
+    const r = parseLogLine(
+      'Jun 08 23:57:28 ubuntu-4gb-fsn1-1 /usr/local/x-ui/x-ui[72297]: INFO - mtproto: started mtg for inbound 3 on 0.0.0.0:8443',
+    );
+    expect(r.stamp).toBe('Jun 08 23:57:28');
+    expect(r.levelText).toBe('INFO');
+    expect(r.service).toBe('X-UI:');
+    expect(r.body).toBe('mtproto: started mtg for inbound 3 on 0.0.0.0:8443');
+  });
+
+  it('xray go-logging line: lifts the XRAY service tag', () => {
+    const r = parseLogLine(
+      'Jun 08 23:56:52 ubuntu-4gb-fsn1-1 /usr/local/x-ui/x-ui[72297]: WARNING - XRAY: core: Xray 26.6.1 started',
+    );
+    expect(r.stamp).toBe('Jun 08 23:56:52');
+    expect(r.levelText).toBe('WARNING');
+    expect(r.service).toBe('XRAY:');
+    expect(r.body).toBe('core: Xray 26.6.1 started');
+  });
+
+  it('Go std-log line: strips the redundant embedded date, keeps the message', () => {
+    const r = parseLogLine(
+      'Jun 08 19:22:22 ubuntu-4gb-fsn1-1 x-ui[1439]: 2026/06/08 19:22:22 http: TLS handshake error from 18.97.5.1:36022: EOF',
+    );
+    expect(r.stamp).toBe('Jun 08 19:22:22');
+    expect(r.levelText).toBe('');
+    expect(r.body).toBe('http: TLS handshake error from 18.97.5.1:36022: EOF');
+  });
+
+  it('telego bracketed line: lifts the ERROR level out of "[ts] ERROR ..."', () => {
+    const r = parseLogLine(
+      'Jun 09 00:14:52 ubuntu-4gb-fsn1-1 x-ui[72297]: [Tue Jun  9 00:14:52 UTC 2026] ERROR Retrying getting updates in 8s...',
+    );
+    expect(r.stamp).toBe('Jun 09 00:14:52');
+    expect(r.levelText).toBe('ERROR');
+    expect(r.body).toBe('Retrying getting updates in 8s...');
+  });
+
+  it('systemd line: shows the body rather than a bare timestamp', () => {
+    const r = parseLogLine(
+      'Jun 08 23:56:47 ubuntu-4gb-fsn1-1 systemd[1]: Stopping x-ui.service - x-ui Service...',
+    );
+    expect(r.stamp).toBe('Jun 08 23:56:47');
+    expect(r.body).toBe('Stopping x-ui.service - x-ui Service...');
+  });
+
+  it('never collapses a journald entry to just its timestamp', () => {
+    const r = parseLogLine(
+      'Jun 09 00:15:00 ubuntu-4gb-fsn1-1 x-ui[72297]: [Tue Jun  9 00:15:00 UTC 2026] ERROR Getting updates: telego: getUpdates: api: 409 "Conflict"',
+    );
+    expect(r.body.length).toBeGreaterThan(0);
+    expect(r.body).toContain('Conflict');
+  });
+});
+
+describe('parseLogLine — app-log format (SysLog off)', () => {
+  it('parses "YYYY/MM/DD HH:MM:SS LEVEL - body"', () => {
+    const r = parseLogLine('2026/06/09 00:35:09 INFO - mtproto: started mtg for inbound 3 on 0.0.0.0:8443');
+    expect(r.date).toBe('2026/06/09');
+    expect(r.time).toBe('00:35:09');
+    expect(r.levelText).toBe('INFO');
+    expect(r.service).toBe('X-UI:');
+    expect(r.body).toBe('mtproto: started mtg for inbound 3 on 0.0.0.0:8443');
+  });
+
+  it('handles an empty line without throwing', () => {
+    const r = parseLogLine('');
+    expect(r.stamp).toBe('');
+    expect(r.body).toBe('');
+  });
+});

+ 5 - 0
install.sh

@@ -1177,6 +1177,11 @@ install_x-ui() {
         else
             systemctl stop x-ui
         fi
+        # Kill any leftover mtg (MTProto) sidecars. x-ui runs them outside its own
+        # lifecycle, so on Linux a stale one can survive the stop and keep holding
+        # an inbound port with an outdated secret, silently breaking new clients.
+        # The freshly installed panel respawns a clean mtg per inbound on start.
+        pkill -f 'mtg-linux-[^ ]* run ' > /dev/null 2>&1 || true
         rm ${xui_folder}/ -rf
     fi
 

+ 23 - 4
mtproto/manager.go

@@ -58,6 +58,9 @@ type managed struct {
 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 (
@@ -107,9 +110,24 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
 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)
+	}
+}
+
 func (m *Manager) ensureLocked(inst Instance) error {
 	fp := inst.fingerprint()
 	if cur, ok := m.procs[inst.Id]; ok {
@@ -128,7 +146,7 @@ func (m *Manager) ensureLocked(inst Instance) error {
 	if err := writeConfig(cfgPath, inst.Secret, inst.bindTo(), metricsPort); err != nil {
 		return err
 	}
-	proc := newProcess(cfgPath)
+	proc := newProcess(cfgPath, fmt.Sprintf("inbound %d", inst.Id))
 	if err := proc.Start(); err != nil {
 		return err
 	}
@@ -138,7 +156,7 @@ func (m *Manager) ensureLocked(inst Instance) error {
 		fingerprint: fp,
 		metricsPort: metricsPort,
 	}
-	logger.Info("mtproto: started mtg for inbound", inst.Id, "on", inst.bindTo())
+	logger.Infof("mtproto: started mtg for inbound %d on %s", inst.Id, inst.bindTo())
 	return nil
 }
 
@@ -150,7 +168,7 @@ func (m *Manager) Remove(id int) {
 		cur.proc.Stop()
 		delete(m.procs, id)
 		_ = os.Remove(configPathForID(id))
-		logger.Info("mtproto: stopped mtg for inbound", id)
+		logger.Infof("mtproto: stopped mtg for inbound %d", id)
 	}
 }
 
@@ -160,6 +178,7 @@ func (m *Manager) Remove(id int) {
 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{}{}
@@ -173,7 +192,7 @@ func (m *Manager) Reconcile(desired []Instance) {
 	}
 	for _, inst := range desired {
 		if err := m.ensureLocked(inst); err != nil {
-			logger.Warning("mtproto: reconcile failed for inbound", inst.Id, ":", err)
+			logger.Warningf("mtproto: reconcile failed for inbound %d: %v", inst.Id, err)
 		}
 	}
 }

+ 79 - 0
mtproto/orphans_linux.go

@@ -0,0 +1,79 @@
+//go:build linux
+
+package mtproto
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"syscall"
+)
+
+// killStrayMtgProcesses terminates orphaned mtg sidecars left over from a
+// previous x-ui run and returns how many were killed.
+//
+// x-ui starts one mtg process per mtproto inbound outside its own lifecycle, and
+// on Linux a child is not guaranteed to die with the panel (there is no
+// kill-on-exit, unlike the Windows job object). A survivor keeps holding the
+// inbound port with a now-stale secret, so new clients are silently
+// domain-fronted to the FakeTLS domain instead of proxied to Telegram. x-ui is
+// the sole owner of mtg, so any process matching our binary name at startup is
+// an orphan and is safe to kill before we start our own.
+//
+// binaryPath is the configured mtg path (e.g. "bin/mtg-linux-amd64"); matching
+// is done on the executable's base name so it is independent of the bin folder
+// and still works after an update has deleted the binary (the running process's
+// /proc/<pid>/exe then reads as "<path> (deleted)", so argv[0] is used too).
+func killStrayMtgProcesses(binaryPath string) int {
+	base := filepath.Base(binaryPath)
+	if base == "" || base == "." || base == string(filepath.Separator) {
+		return 0
+	}
+	self := os.Getpid()
+	entries, err := os.ReadDir("/proc")
+	if err != nil {
+		return 0
+	}
+	killed := 0
+	for _, e := range entries {
+		pid, err := strconv.Atoi(e.Name())
+		if err != nil || pid == self {
+			continue
+		}
+		if procExeBase(pid) != base && cmdlineArgv0Base(pid) != base {
+			continue
+		}
+		if err := syscall.Kill(pid, syscall.SIGKILL); err == nil {
+			killed++
+		}
+	}
+	return killed
+}
+
+// procExeBase returns the base name of /proc/<pid>/exe, or "" if unreadable.
+func procExeBase(pid int) string {
+	exe, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))
+	if err != nil {
+		return ""
+	}
+	return filepath.Base(exe)
+}
+
+// cmdlineArgv0Base returns the base name of argv[0] from /proc/<pid>/cmdline,
+// the reliable fallback when the binary has been replaced or exe is unreadable.
+func cmdlineArgv0Base(pid int) string {
+	data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
+	if err != nil || len(data) == 0 {
+		return ""
+	}
+	argv0 := data
+	if i := strings.IndexByte(string(data), 0); i >= 0 {
+		argv0 = data[:i]
+	}
+	if len(argv0) == 0 {
+		return ""
+	}
+	return filepath.Base(string(argv0))
+}

+ 9 - 0
mtproto/orphans_other.go

@@ -0,0 +1,9 @@
+//go:build !linux
+
+package mtproto
+
+// killStrayMtgProcesses is a no-op off Linux. On Windows the kill-on-exit job
+// object already terminates mtg together with the panel (see
+// attachChildLifetime), so orphans do not arise there; other platforms are not
+// a supported deployment target for the mtg sidecar.
+func killStrayMtgProcesses(_ string) int { return 0 }

+ 46 - 12
mtproto/process.go

@@ -49,22 +49,55 @@ var (
 	forceStopTimeout    = 2 * time.Second
 )
 
-type lastLineWriter struct {
+// 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 *lastLineWriter) Write(p []byte) (int, error) {
-	line := strings.TrimSpace(string(p))
-	if line != "" {
-		w.mu.Lock()
-		w.lastLine = line
-		w.mu.Unlock()
+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
 }
 
-func (w *lastLineWriter) LastLine() string {
+// 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
@@ -75,15 +108,15 @@ type Process struct {
 	cmd             *exec.Cmd
 	done            chan struct{}
 	configPath      string
-	logWriter       *lastLineWriter
+	logWriter       *procLogWriter
 	exitErr         error
 	intentionalStop atomic.Bool
 }
 
-func newProcess(configPath string) *Process {
+func newProcess(configPath, label string) *Process {
 	return &Process{
 		configPath: configPath,
-		logWriter:  &lastLineWriter{},
+		logWriter:  &procLogWriter{label: label},
 	}
 }
 
@@ -141,6 +174,7 @@ func (p *Process) Start() error {
 func (p *Process) wait(cmd *exec.Cmd) {
 	defer close(p.done)
 	err := cmd.Wait()
+	p.logWriter.Flush()
 	if err == nil || p.intentionalStop.Load() {
 		return
 	}
@@ -150,7 +184,7 @@ func (p *Process) wait(cmd *exec.Cmd) {
 			return
 		}
 	}
-	logger.Error("mtproto: mtg process exited:", err)
+	logger.Errorf("mtproto: mtg process exited: %v", err)
 	p.exitErr = err
 }
 

+ 3 - 3
mtproto/process_windows.go

@@ -51,16 +51,16 @@ func attachChildLifetime(cmd *exec.Cmd) {
 	}
 	job, err := ensureKillOnExitJob()
 	if err != nil {
-		logger.Warning("mtproto: kill-on-exit job unavailable:", err)
+		logger.Warningf("mtproto: kill-on-exit job unavailable: %v", err)
 		return
 	}
 	h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))
 	if err != nil {
-		logger.Warning("mtproto: OpenProcess for job attach failed:", err)
+		logger.Warningf("mtproto: OpenProcess for job attach failed: %v", err)
 		return
 	}
 	defer windows.CloseHandle(h)
 	if err := windows.AssignProcessToJobObject(job, h); err != nil {
-		logger.Warning("mtproto: AssignProcessToJobObject failed:", err)
+		logger.Warningf("mtproto: AssignProcessToJobObject failed: %v", err)
 	}
 }

+ 2 - 3
sub/subService.go

@@ -381,8 +381,7 @@ func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
 }
 
 // genMtprotoLink builds a Telegram proxy deep link for an mtproto inbound:
-// tg://proxy?server=<addr>&port=<port>&secret=<ee FakeTLS secret>.
-func (s *SubService) genMtprotoLink(inbound *model.Inbound, email string) string {
+func (s *SubService) genMtprotoLink(inbound *model.Inbound, _ string) string {
 	if inbound.Protocol != model.MTProto {
 		return ""
 	}
@@ -403,7 +402,7 @@ func (s *SubService) genMtprotoLink(inbound *model.Inbound, email string) string
 		"port":   fmt.Sprintf("%d", inbound.Port),
 		"secret": secret,
 	}
-	return buildLinkWithParams("tg://proxy", params, s.genRemark(inbound, email, ""))
+	return buildLinkWithParams("tg://proxy", params, "")
 }
 
 // Protocol link generators are intentionally ordered as:

+ 5 - 0
update.sh

@@ -890,6 +890,11 @@ update_x-ui() {
                 _fail "ERROR: x-ui systemd unit not installed."
             fi
         fi
+        # Kill any leftover mtg (MTProto) sidecars. x-ui runs them outside its own
+        # lifecycle, so on Linux a stale one can survive the stop and keep holding
+        # an inbound port with an outdated secret, silently breaking new clients.
+        # The new panel respawns a clean mtg per inbound on next start.
+        pkill -f 'mtg-linux-[^ ]* run ' > /dev/null 2>&1 || true
         echo -e "${green}Removing old x-ui version...${plain}"
         rm ${xui_folder} -f > /dev/null 2>&1
         rm ${xui_folder}/x-ui.service -f > /dev/null 2>&1

+ 29 - 0
x-ui.sh

@@ -864,6 +864,7 @@ show_status() {
             ;;
     esac
     show_xray_status
+    show_mtproto_status
 }
 
 show_enable_status() {
@@ -897,6 +898,34 @@ show_xray_status() {
     fi
 }
 
+# show_mtproto_status reports each mtproto inbound's mtg sidecar (one process per
+# inbound, run outside xray). Silent when no mtproto inbound is configured.
+show_mtproto_status() {
+    local cfg_dir="${xui_folder}/bin/mtproto"
+    local cfgs=()
+    if [[ -d "${cfg_dir}" ]]; then
+        for f in "${cfg_dir}"/mtg-*.toml; do
+            [[ -e "$f" ]] && cfgs+=("$f")
+        done
+    fi
+    [[ ${#cfgs[@]} -eq 0 ]] && return
+
+    local running
+    running=$(ps -ef | grep "mtg-linux" | grep -v "grep" | grep -oE 'mtg-[0-9]+\.toml')
+    for f in "${cfgs[@]}"; do
+        local name id bind
+        name=$(basename "$f")
+        id=$(echo "${name}" | sed -E 's/mtg-([0-9]+)\.toml/\1/')
+        bind=$(grep -E '^[[:space:]]*bind-to' "$f" | head -1 | cut -d'"' -f2)
+        if echo "${running}" | grep -qx "${name}"; then
+            echo -e "mtproto inbound ${id} (${bind}): ${green}Running${plain}"
+        else
+            echo -e "mtproto inbound ${id} (${bind}): ${red}Not Running${plain}"
+        fi
+    done
+    echo -e "  ${yellow}mtg logs:${plain} journalctl -u x-ui --no-pager -n 200 | grep -i mtproto"
+}
+
 firewall_menu() {
     echo -e "${green}\t1.${plain} ${green}Install${plain} Firewall"
     echo -e "${green}\t2.${plain} Port List [numbered]"