Skip to content

Commit f8e89cc

Browse files
authored
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).
1 parent 9711a9c commit f8e89cc

13 files changed

Lines changed: 397 additions & 71 deletions

File tree

frontend/src/lib/xray/inbound-link.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -684,21 +684,18 @@ export interface GenMtprotoLinkInput {
684684
inbound: Inbound;
685685
address: string;
686686
port?: number;
687-
remark?: string;
688687
}
689688

690689
// Builds a Telegram proxy deep link for an mtproto inbound:
691-
// tg://proxy?server=<addr>&port=<port>&secret=<ee FakeTLS secret>.
692690
export function genMtprotoLink(input: GenMtprotoLinkInput): string {
693-
const { inbound, address, port = inbound.port, remark = '' } = input;
691+
const { inbound, address, port = inbound.port } = input;
694692
if (inbound.protocol !== 'mtproto') return '';
695693
const secret = inbound.settings.secret ?? '';
696694
if (secret.length === 0) return '';
697695
const url = new URL('tg://proxy');
698696
url.searchParams.set('server', address);
699697
url.searchParams.set('port', String(port));
700698
url.searchParams.set('secret', secret);
701-
url.hash = encodeURIComponent(remark);
702699
return url.toString();
703700
}
704701

@@ -890,7 +887,7 @@ export function genLink(input: GenLinkInput): string {
890887
externalProxy,
891888
});
892889
case 'mtproto':
893-
return genMtprotoLink({ inbound, address, port, remark });
890+
return genMtprotoLink({ inbound, address, port });
894891
default:
895892
return '';
896893
}

frontend/src/pages/index/LogModal.tsx

Lines changed: 1 addition & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,57 +5,14 @@ import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
55

66
import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
77
import { useMediaQuery } from '@/hooks/useMediaQuery';
8+
import { parseLogLine } from './logParse';
89
import './LogModal.css';
910

1011
interface LogModalProps {
1112
open: boolean;
1213
onClose: () => void;
1314
}
1415

15-
interface ParsedLog {
16-
date: string;
17-
time: string;
18-
stamp: string;
19-
levelText: string;
20-
levelClass: string;
21-
service: string;
22-
body: string;
23-
}
24-
25-
const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'];
26-
const LEVEL_CLASSES = ['level-debug', 'level-info', 'level-notice', 'level-warning', 'level-error'];
27-
28-
function parseLogLine(line: string): ParsedLog {
29-
const [head, ...rest] = (line || '').split(' - ');
30-
const message = rest.join(' - ');
31-
const parts = head.split(' ');
32-
33-
let date = '';
34-
let time = '';
35-
let levelText: string;
36-
if (parts.length >= 3) {
37-
[date, time, levelText] = parts;
38-
} else {
39-
levelText = head;
40-
}
41-
42-
const li = LEVELS.indexOf(levelText);
43-
const levelClass = li >= 0 ? LEVEL_CLASSES[li] : 'level-unknown';
44-
45-
let service = '';
46-
let body = message || '';
47-
if (body.startsWith('XRAY:')) {
48-
service = 'XRAY:';
49-
body = body.slice('XRAY:'.length).trimStart();
50-
} else if (body) {
51-
service = 'X-UI:';
52-
}
53-
54-
const stamp = [date, time].filter(Boolean).join(' ');
55-
56-
return { date, time, stamp, levelText, levelClass, service, body };
57-
}
58-
5916
export default function LogModal({ open, onClose }: LogModalProps) {
6017
const { t } = useTranslation();
6118
const { isMobile } = useMediaQuery();
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Parser for the panel log viewer. Logs reach the UI in two shapes:
2+
//
3+
// - App log (SysLog off): the in-memory buffer, formatted as
4+
// "2006/01/02 15:04:05 LEVEL - message"
5+
// - SysLog (journalctl -o short): every entry is prefixed with
6+
// "Mon DD HH:MM:SS host ident[pid]: " before the real message, and the
7+
// message itself is one of several shapes depending on which subsystem
8+
// emitted it:
9+
// "INFO - mtproto: ..." go-logging (x-ui + xray)
10+
// "2026/06/08 19:22:22 http: ..." Go std log (net/http, runtime)
11+
// "[Mon Jun 8 23:56:52 UTC 2026] ERROR ..." telego bot
12+
// "Stopping x-ui.service - ..." systemd
13+
//
14+
// parseLogLine normalises all of these into a stamp + level + service + body so
15+
// the viewer renders a readable line instead of a bare timestamp.
16+
17+
export interface ParsedLog {
18+
date: string;
19+
time: string;
20+
stamp: string;
21+
levelText: string;
22+
levelClass: string;
23+
service: string;
24+
body: string;
25+
}
26+
27+
export const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'];
28+
export const LEVEL_CLASSES = [
29+
'level-debug',
30+
'level-info',
31+
'level-notice',
32+
'level-warning',
33+
'level-error',
34+
];
35+
36+
// "Mon DD HH:MM:SS host ident[pid]: <message>" — captures the journal date,
37+
// time, and the message that follows the syslog identifier.
38+
const SYSLOG_PREFIX = /^([A-Za-z]{3}\s+\d{1,2})\s+(\d{2}:\d{2}:\d{2})\s+\S+\s+\S+?:\s+(.*)$/;
39+
// Redundant Go std-log date prefix ("2006/01/02 15:04:05 ") to strip — the
40+
// journal already carries the timestamp.
41+
const GO_LOG_DATE = /^\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2}\s+/;
42+
// telego's own line prefix: "[Mon Jan _2 15:04:05 MST 2006] LEVEL rest".
43+
const TELEGO = /^\[[^\]]+\]\s+([A-Z]+)\s+(.*)$/;
44+
45+
// splitLevelDash pulls a leading "LEVEL - " off a message, returning the level
46+
// and the remainder. Returns null when the message does not start with a level.
47+
function splitLevelDash(message: string): { level: string; rest: string } | null {
48+
const dash = message.indexOf(' - ');
49+
if (dash < 0) return null;
50+
const level = message.slice(0, dash).trim();
51+
if (LEVELS.indexOf(level) < 0) return null;
52+
return { level, rest: message.slice(dash + 3) };
53+
}
54+
55+
export function parseLogLine(line: string): ParsedLog {
56+
const raw = (line || '').trim();
57+
58+
let date = '';
59+
let time = '';
60+
let levelText = '';
61+
let body: string;
62+
63+
const sys = raw.match(SYSLOG_PREFIX);
64+
if (sys) {
65+
date = sys[1];
66+
time = sys[2];
67+
let message = sys[3];
68+
69+
const ld = splitLevelDash(message);
70+
if (ld) {
71+
// go-logging: "LEVEL - message"
72+
levelText = ld.level;
73+
body = ld.rest;
74+
} else {
75+
// Strip the redundant Go std-log date, then try to lift a level out of a
76+
// telego "[timestamp] LEVEL ..." line; otherwise keep the message as-is.
77+
message = message.replace(GO_LOG_DATE, '');
78+
const tg = message.match(TELEGO);
79+
if (tg && LEVELS.indexOf(tg[1]) >= 0) {
80+
levelText = tg[1];
81+
body = tg[2];
82+
} else {
83+
body = message;
84+
}
85+
}
86+
} else {
87+
// App-log format: "2006/01/02 15:04:05 LEVEL - body"
88+
const [head, ...rest] = raw.split(' - ');
89+
const message = rest.join(' - ');
90+
const parts = head.split(' ');
91+
if (parts.length >= 3) {
92+
[date, time, levelText] = parts;
93+
} else {
94+
levelText = head;
95+
}
96+
body = message || '';
97+
}
98+
99+
const li = LEVELS.indexOf(levelText);
100+
const levelClass = li >= 0 ? LEVEL_CLASSES[li] : 'level-unknown';
101+
102+
let service = '';
103+
if (body.startsWith('XRAY:')) {
104+
service = 'XRAY:';
105+
body = body.slice('XRAY:'.length).trimStart();
106+
} else if (body) {
107+
service = 'X-UI:';
108+
}
109+
110+
const stamp = [date, time].filter(Boolean).join(' ');
111+
112+
return { date, time, stamp, levelText, levelClass, service, body };
113+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, it, expect } from 'vitest';
2+
3+
import { parseLogLine } from '@/pages/index/logParse';
4+
5+
// Fixtures are real lines captured from `journalctl -u x-ui` on a production
6+
// host (the SysLog view) plus the in-memory app-log format. Each journald entry
7+
// carries a "Mon DD HH:MM:SS host ident[pid]: " prefix that the viewer used to
8+
// mistake for the level, leaving only a bare timestamp on screen.
9+
describe('parseLogLine — SysLog (journalctl) formats', () => {
10+
it('x-ui go-logging line: keeps level, strips prefix, tags X-UI', () => {
11+
const r = parseLogLine(
12+
'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',
13+
);
14+
expect(r.stamp).toBe('Jun 08 23:57:28');
15+
expect(r.levelText).toBe('INFO');
16+
expect(r.service).toBe('X-UI:');
17+
expect(r.body).toBe('mtproto: started mtg for inbound 3 on 0.0.0.0:8443');
18+
});
19+
20+
it('xray go-logging line: lifts the XRAY service tag', () => {
21+
const r = parseLogLine(
22+
'Jun 08 23:56:52 ubuntu-4gb-fsn1-1 /usr/local/x-ui/x-ui[72297]: WARNING - XRAY: core: Xray 26.6.1 started',
23+
);
24+
expect(r.stamp).toBe('Jun 08 23:56:52');
25+
expect(r.levelText).toBe('WARNING');
26+
expect(r.service).toBe('XRAY:');
27+
expect(r.body).toBe('core: Xray 26.6.1 started');
28+
});
29+
30+
it('Go std-log line: strips the redundant embedded date, keeps the message', () => {
31+
const r = parseLogLine(
32+
'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',
33+
);
34+
expect(r.stamp).toBe('Jun 08 19:22:22');
35+
expect(r.levelText).toBe('');
36+
expect(r.body).toBe('http: TLS handshake error from 18.97.5.1:36022: EOF');
37+
});
38+
39+
it('telego bracketed line: lifts the ERROR level out of "[ts] ERROR ..."', () => {
40+
const r = parseLogLine(
41+
'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...',
42+
);
43+
expect(r.stamp).toBe('Jun 09 00:14:52');
44+
expect(r.levelText).toBe('ERROR');
45+
expect(r.body).toBe('Retrying getting updates in 8s...');
46+
});
47+
48+
it('systemd line: shows the body rather than a bare timestamp', () => {
49+
const r = parseLogLine(
50+
'Jun 08 23:56:47 ubuntu-4gb-fsn1-1 systemd[1]: Stopping x-ui.service - x-ui Service...',
51+
);
52+
expect(r.stamp).toBe('Jun 08 23:56:47');
53+
expect(r.body).toBe('Stopping x-ui.service - x-ui Service...');
54+
});
55+
56+
it('never collapses a journald entry to just its timestamp', () => {
57+
const r = parseLogLine(
58+
'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"',
59+
);
60+
expect(r.body.length).toBeGreaterThan(0);
61+
expect(r.body).toContain('Conflict');
62+
});
63+
});
64+
65+
describe('parseLogLine — app-log format (SysLog off)', () => {
66+
it('parses "YYYY/MM/DD HH:MM:SS LEVEL - body"', () => {
67+
const r = parseLogLine('2026/06/09 00:35:09 INFO - mtproto: started mtg for inbound 3 on 0.0.0.0:8443');
68+
expect(r.date).toBe('2026/06/09');
69+
expect(r.time).toBe('00:35:09');
70+
expect(r.levelText).toBe('INFO');
71+
expect(r.service).toBe('X-UI:');
72+
expect(r.body).toBe('mtproto: started mtg for inbound 3 on 0.0.0.0:8443');
73+
});
74+
75+
it('handles an empty line without throwing', () => {
76+
const r = parseLogLine('');
77+
expect(r.stamp).toBe('');
78+
expect(r.body).toBe('');
79+
});
80+
});

install.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1177,6 +1177,11 @@ install_x-ui() {
11771177
else
11781178
systemctl stop x-ui
11791179
fi
1180+
# Kill any leftover mtg (MTProto) sidecars. x-ui runs them outside its own
1181+
# lifecycle, so on Linux a stale one can survive the stop and keep holding
1182+
# an inbound port with an outdated secret, silently breaking new clients.
1183+
# The freshly installed panel respawns a clean mtg per inbound on start.
1184+
pkill -f 'mtg-linux-[^ ]* run ' > /dev/null 2>&1 || true
11801185
rm ${xui_folder}/ -rf
11811186
fi
11821187

mtproto/manager.go

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ type managed struct {
5858
type Manager struct {
5959
mu sync.Mutex
6060
procs map[int]*managed
61+
// swept records that the one-time startup cleanup of orphaned mtg
62+
// processes (survivors of a previous x-ui run) has already run.
63+
swept bool
6164
}
6265

6366
var (
@@ -107,9 +110,24 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
107110
func (m *Manager) Ensure(inst Instance) error {
108111
m.mu.Lock()
109112
defer m.mu.Unlock()
113+
m.sweepOrphansLocked()
110114
return m.ensureLocked(inst)
111115
}
112116

117+
// sweepOrphansLocked kills mtg processes left running by a previous x-ui run,
118+
// exactly once per process lifetime and before any of our own mtg are started.
119+
// Because x-ui owns every mtg process, anything alive at this point is an orphan
120+
// that would otherwise keep holding an inbound port with a stale secret.
121+
func (m *Manager) sweepOrphansLocked() {
122+
if m.swept {
123+
return
124+
}
125+
m.swept = true
126+
if n := killStrayMtgProcesses(GetBinaryPath()); n > 0 {
127+
logger.Warningf("mtproto: terminated %d orphaned mtg process(es) from a previous run", n)
128+
}
129+
}
130+
113131
func (m *Manager) ensureLocked(inst Instance) error {
114132
fp := inst.fingerprint()
115133
if cur, ok := m.procs[inst.Id]; ok {
@@ -128,7 +146,7 @@ func (m *Manager) ensureLocked(inst Instance) error {
128146
if err := writeConfig(cfgPath, inst.Secret, inst.bindTo(), metricsPort); err != nil {
129147
return err
130148
}
131-
proc := newProcess(cfgPath)
149+
proc := newProcess(cfgPath, fmt.Sprintf("inbound %d", inst.Id))
132150
if err := proc.Start(); err != nil {
133151
return err
134152
}
@@ -138,7 +156,7 @@ func (m *Manager) ensureLocked(inst Instance) error {
138156
fingerprint: fp,
139157
metricsPort: metricsPort,
140158
}
141-
logger.Info("mtproto: started mtg for inbound", inst.Id, "on", inst.bindTo())
159+
logger.Infof("mtproto: started mtg for inbound %d on %s", inst.Id, inst.bindTo())
142160
return nil
143161
}
144162

@@ -150,7 +168,7 @@ func (m *Manager) Remove(id int) {
150168
cur.proc.Stop()
151169
delete(m.procs, id)
152170
_ = os.Remove(configPathForID(id))
153-
logger.Info("mtproto: stopped mtg for inbound", id)
171+
logger.Infof("mtproto: stopped mtg for inbound %d", id)
154172
}
155173
}
156174

@@ -160,6 +178,7 @@ func (m *Manager) Remove(id int) {
160178
func (m *Manager) Reconcile(desired []Instance) {
161179
m.mu.Lock()
162180
defer m.mu.Unlock()
181+
m.sweepOrphansLocked()
163182
want := make(map[int]struct{}, len(desired))
164183
for _, inst := range desired {
165184
want[inst.Id] = struct{}{}
@@ -173,7 +192,7 @@ func (m *Manager) Reconcile(desired []Instance) {
173192
}
174193
for _, inst := range desired {
175194
if err := m.ensureLocked(inst); err != nil {
176-
logger.Warning("mtproto: reconcile failed for inbound", inst.Id, ":", err)
195+
logger.Warningf("mtproto: reconcile failed for inbound %d: %v", inst.Id, err)
177196
}
178197
}
179198
}

0 commit comments

Comments
 (0)