4 Commits f8e89cc848 ... fe62c39a53

Autor SHA1 Mensaje Fecha
  Rouzbeh† fe62c39a53 fix: inbound edit validation failure and legacy copy to clipboard (#5132) hace 7 horas
  MHSanaei 2969f6e91d fix(client): preserve UUID/password/auth on partial client update (#5111) hace 10 horas
  MHSanaei 0bed552292 fix(outbound): include tested outbound in HTTP probe config (#5120) hace 10 horas
  MHSanaei 6c1594693d feat(mtproto): add domain-fronting and essential mtg options hace 11 horas
Se han modificado 43 ficheros con 723 adiciones y 105 borrados
  1. 1 0
      frontend/src/lib/xray/inbound-defaults.ts
  2. 4 1
      frontend/src/lib/xray/inbound-form-adapter.ts
  3. 6 0
      frontend/src/lib/xray/protocol-capabilities.ts
  4. 27 21
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  5. 7 3
      frontend/src/pages/inbounds/form/advanced-editors.tsx
  6. 39 3
      frontend/src/pages/inbounds/form/protocols/mtproto.tsx
  7. 41 0
      frontend/src/pages/inbounds/info/InboundInfoModal.tsx
  8. 1 1
      frontend/src/schemas/protocols/inbound/hysteria.ts
  9. 16 0
      frontend/src/schemas/protocols/inbound/mtproto.ts
  10. 1 1
      frontend/src/schemas/protocols/inbound/shadowsocks.ts
  11. 1 1
      frontend/src/schemas/protocols/inbound/trojan.ts
  12. 2 2
      frontend/src/schemas/protocols/inbound/vless.ts
  13. 3 2
      frontend/src/schemas/protocols/inbound/vmess.ts
  14. 1 0
      frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap
  15. 1 0
      frontend/src/test/__snapshots__/inbound-full.test.ts.snap
  16. 168 0
      frontend/src/test/__snapshots__/protocol-capabilities.test.ts.snap
  17. 19 0
      frontend/src/test/__snapshots__/protocols.test.ts.snap
  18. 15 0
      frontend/src/test/golden/fixtures/inbound/mtproto-domain-fronting.json
  19. 11 0
      frontend/src/test/inbound-form-adapter.test.ts
  20. 25 32
      frontend/src/utils/index.ts
  21. 77 12
      mtproto/manager.go
  22. 72 1
      mtproto/manager_test.go
  23. 6 4
      util/link/outbound.go
  24. 1 1
      util/link/outbound_test.go
  25. 1 1
      web/job/outbound_subscription_job.go
  26. 39 2
      web/service/client.go
  27. 20 2
      web/service/outbound.go
  28. 1 1
      web/service/outbound_subscription.go
  29. 26 0
      web/service/outbound_subscription_test.go
  30. 7 1
      web/translation/ar-EG.json
  31. 7 1
      web/translation/en-US.json
  32. 7 1
      web/translation/es-ES.json
  33. 7 1
      web/translation/fa-IR.json
  34. 7 1
      web/translation/id-ID.json
  35. 7 1
      web/translation/ja-JP.json
  36. 7 1
      web/translation/pt-BR.json
  37. 7 1
      web/translation/ru-RU.json
  38. 7 1
      web/translation/tr-TR.json
  39. 7 1
      web/translation/uk-UA.json
  40. 7 1
      web/translation/vi-VN.json
  41. 7 1
      web/translation/zh-CN.json
  42. 7 1
      web/translation/zh-TW.json
  43. 0 1
      x-ui.sh

+ 1 - 0
frontend/src/lib/xray/inbound-defaults.ts

@@ -81,6 +81,7 @@ export function createDefaultVmessClient(seed: VmessClientSeed = {}): VmessClien
   return {
     id: seed.id ?? RandomUtil.randomUUID(),
     security: seed.security ?? 'auto',
+    alterId: 0,
     ...clientBase(seed),
   };
 }

+ 4 - 1
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -11,6 +11,7 @@ import type { StreamSettings } from '@/schemas/api/inbound';
 import type { Sniffing } from '@/schemas/primitives';
 import type { z } from 'zod';
 import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
+import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
 
 // Plain-data adapter between the panel's stored inbound row shape and
 // the typed InboundFormValues that Form.useForm<T> carries inside
@@ -302,7 +303,9 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
     protocol: values.protocol,
     settings: JSON.stringify(settingsPruned),
     streamSettings: streamPruned ? JSON.stringify(streamPruned) : '',
-    sniffing: JSON.stringify(normalizeSniffing(values.sniffing)),
+    // mtproto is mtg-served, not Xray, so sniffing never applies — emit empty
+    // rather than the default { enabled: false } so the row carries no sniffing.
+    sniffing: canEnableSniffing({ protocol: values.protocol }) ? JSON.stringify(normalizeSniffing(values.sniffing)) : '',
     tag: values.tag,
   };
   if (values.nodeId != null) payload.nodeId = values.nodeId;

+ 6 - 0
frontend/src/lib/xray/protocol-capabilities.ts

@@ -50,6 +50,12 @@ export function canEnableStream(values: { protocol: string }): boolean {
   return STREAM_PROTOCOLS.includes(values.protocol);
 }
 
+// mtproto is served by an external mtg process, not Xray, so the Xray sniffing
+// block does not apply to it. Every other inbound supports sniffing.
+export function canEnableSniffing(values: { protocol: string }): boolean {
+  return values.protocol !== 'mtproto';
+}
+
 // Vision seed applies only when XTLS Vision (TCP/TLS) flow is selected
 // AND at least one VLESS client uses the vision flow. Excludes UDP variant.
 export function canEnableVisionSeed(values: CapabilityVlessSlice): boolean {

+ 27 - 21
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -23,6 +23,7 @@ import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
 import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag';
 import {
   canEnableReality,
+  canEnableSniffing,
   canEnableStream,
   canEnableTls,
   isSS2022,
@@ -160,6 +161,7 @@ export default function InboundFormModal({
   const network = Form.useWatch(['streamSettings', 'network'], form) ?? '';
   const security = Form.useWatch(['streamSettings', 'security'], form) ?? 'none';
   const streamEnabled = canEnableStream({ protocol });
+  const sniffingSupported = canEnableSniffing({ protocol });
 
   const wPort = Form.useWatch('port', form);
   const wListen = (Form.useWatch('listen', form) ?? '') as string;
@@ -776,7 +778,7 @@ export default function InboundFormModal({
                   <div className="advanced-editor-meta">
                     {t('pages.inbounds.advanced.allHelp')}
                   </div>
-                  <AdvancedAllEditor form={form} streamEnabled={streamEnabled} />
+                  <AdvancedAllEditor form={form} streamEnabled={streamEnabled} sniffingEnabled={sniffingSupported} />
                 </>
               ),
             },
@@ -820,25 +822,27 @@ export default function InboundFormModal({
                 ),
               }]
               : []),
-            {
-              key: 'sniffing',
-              label: t('pages.inbounds.advanced.sniffing'),
-              children: (
-                <>
-                  <div className="advanced-editor-meta">
-                    {t('pages.inbounds.advanced.sniffingHelp')}{' '}
-                    <code>{'{ sniffing: { ... } }'}</code>.
-                  </div>
-                  <AdvancedSliceEditor
-                    form={form}
-                    path="sniffing"
-                    wrapKey="sniffing"
-                    minHeight="240px"
-                    maxHeight="420px"
-                  />
-                </>
-              ),
-            },
+            ...(sniffingSupported
+              ? [{
+                key: 'sniffing',
+                label: t('pages.inbounds.advanced.sniffing'),
+                children: (
+                  <>
+                    <div className="advanced-editor-meta">
+                      {t('pages.inbounds.advanced.sniffingHelp')}{' '}
+                      <code>{'{ sniffing: { ... } }'}</code>.
+                    </div>
+                    <AdvancedSliceEditor
+                      form={form}
+                      path="sniffing"
+                      wrapKey="sniffing"
+                      minHeight="240px"
+                      maxHeight="420px"
+                    />
+                  </>
+                ),
+              }]
+              : []),
           ]}
         />
       </div>
@@ -896,7 +900,9 @@ export default function InboundFormModal({
                 { key: 'security', label: t('pages.inbounds.securityTab'), children: securityTab, forceRender: true },
               ]
               : []),
-            { key: 'sniffing', label: t('pages.inbounds.sniffingTab'), children: sniffingTab, forceRender: true },
+            ...(sniffingSupported
+              ? [{ key: 'sniffing', label: t('pages.inbounds.sniffingTab'), children: sniffingTab, forceRender: true }]
+              : []),
             { key: 'advanced', label: t('pages.xray.advancedTemplate'), children: advancedTab, forceRender: true },
           ]} />
         </Form>

+ 7 - 3
frontend/src/pages/inbounds/form/advanced-editors.tsx

@@ -92,9 +92,11 @@ export function AdvancedSliceEditor({
 export function AdvancedAllEditor({
   form,
   streamEnabled,
+  sniffingEnabled,
 }: {
   form: FormInstance<InboundFormValues>;
   streamEnabled: boolean;
+  sniffingEnabled: boolean;
 }) {
   // preserve: true — default useWatch returns only registered fields, so
   // sub-trees we never bound (settings.clients/fallbacks, sniffing
@@ -127,8 +129,10 @@ export function AdvancedAllEditor({
       protocol: wProtocol ?? '',
       tag: wTag ?? '',
       settings: settingsView,
-      sniffing: normalizeSniffing(wSniffing as Parameters<typeof normalizeSniffing>[0]),
     };
+    if (sniffingEnabled) {
+      out.sniffing = normalizeSniffing(wSniffing as Parameters<typeof normalizeSniffing>[0]);
+    }
     if (streamView) out.streamSettings = streamView;
     return JSON.stringify(out, null, 2);
   };
@@ -146,7 +150,7 @@ export function AdvancedAllEditor({
     setText(formStr);
     lastEmitRef.current = formStr;
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [wListen, wPort, wProtocol, wTag, wSettings, wSniffing, wStream, streamEnabled]);
+  }, [wListen, wPort, wProtocol, wTag, wSettings, wSniffing, wStream, streamEnabled, sniffingEnabled]);
 
   return (
     <JsonEditor
@@ -171,7 +175,7 @@ export function AdvancedAllEditor({
         if (parsed.settings && typeof parsed.settings === 'object') {
           form.setFieldValue('settings', parsed.settings);
         }
-        if (parsed.sniffing && typeof parsed.sniffing === 'object') {
+        if (sniffingEnabled && parsed.sniffing && typeof parsed.sniffing === 'object') {
           form.setFieldValue('sniffing', parsed.sniffing);
         }
         if (streamEnabled && parsed.streamSettings && typeof parsed.streamSettings === 'object') {

+ 39 - 3
frontend/src/pages/inbounds/form/protocols/mtproto.tsx

@@ -1,5 +1,5 @@
 import { useTranslation } from 'react-i18next';
-import { Alert, Button, Form, Input, Space } from 'antd';
+import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
 import { ReloadOutlined } from '@ant-design/icons';
 
 import { generateMtprotoSecret, mtprotoSecretForDomain } from '@/lib/xray/inbound-defaults';
@@ -32,8 +32,44 @@ export default function MtprotoFields() {
           />
         </Space.Compact>
       </Form.Item>
-      <Form.Item wrapperCol={{ span: 24 }}>
-        <Alert type="info" showIcon message={t('pages.inbounds.form.mtprotoHint')} />
+      <Form.Item
+        name={['settings', 'domainFronting', 'ip']}
+        label={t('pages.inbounds.form.mtgDomainFrontingIp')}
+        tooltip={t('pages.inbounds.form.mtgDomainFrontingHint')}
+      >
+        <Input placeholder="127.0.0.1" />
+      </Form.Item>
+      <Form.Item name={['settings', 'domainFronting', 'port']} label={t('pages.inbounds.form.mtgDomainFrontingPort')}>
+        <InputNumber min={0} max={65535} placeholder="443" style={{ width: '100%' }} />
+      </Form.Item>
+      <Form.Item
+        name={['settings', 'domainFronting', 'proxyProtocol']}
+        label={t('pages.inbounds.form.mtgDomainFrontingProxyProtocol')}
+        valuePropName="checked"
+      >
+        <Switch />
+      </Form.Item>
+      <Form.Item
+        name={['settings', 'proxyProtocolListener']}
+        label={t('pages.inbounds.form.mtgProxyProtocolListener')}
+        valuePropName="checked"
+      >
+        <Switch />
+      </Form.Item>
+      <Form.Item name={['settings', 'preferIp']} label={t('pages.inbounds.form.mtgPreferIp')}>
+        <Select
+          allowClear
+          placeholder="prefer-ipv6"
+          options={[
+            { value: 'prefer-ipv6', label: 'prefer-ipv6' },
+            { value: 'prefer-ipv4', label: 'prefer-ipv4' },
+            { value: 'only-ipv6', label: 'only-ipv6' },
+            { value: 'only-ipv4', label: 'only-ipv4' },
+          ]}
+        />
+      </Form.Item>
+      <Form.Item name={['settings', 'debug']} label={t('pages.inbounds.form.mtgDebug')} valuePropName="checked">
+        <Switch />
       </Form.Item>
     </>
   );

+ 41 - 0
frontend/src/pages/inbounds/info/InboundInfoModal.tsx

@@ -640,6 +640,47 @@ export default function InboundInfoModal({
               </Tooltip>
             </dd>
           </div>
+          {(() => {
+            const s = inbound.settings;
+            const df = s.domainFronting as { ip?: string; port?: number; proxyProtocol?: boolean } | undefined;
+            const frontingTarget = df && (df.ip || df.port)
+              ? `${df.ip ?? ''}${df.port ? `:${df.port}` : ''}`
+              : '';
+            return (
+              <>
+                {frontingTarget && (
+                  <div className="info-row">
+                    <dt>{t('pages.inbounds.form.mtgDomainFrontingIp')}</dt>
+                    <dd><Tag color="blue" className="value-tag">{frontingTarget}</Tag></dd>
+                  </div>
+                )}
+                {df?.proxyProtocol && (
+                  <div className="info-row">
+                    <dt>{t('pages.inbounds.form.mtgDomainFrontingProxyProtocol')}</dt>
+                    <dd><Tag color="green" className="value-tag">{t('enabled')}</Tag></dd>
+                  </div>
+                )}
+                {Boolean(s.proxyProtocolListener) && (
+                  <div className="info-row">
+                    <dt>{t('pages.inbounds.form.mtgProxyProtocolListener')}</dt>
+                    <dd><Tag color="green" className="value-tag">{t('enabled')}</Tag></dd>
+                  </div>
+                )}
+                {Boolean(s.preferIp) && (
+                  <div className="info-row">
+                    <dt>{t('pages.inbounds.form.mtgPreferIp')}</dt>
+                    <dd><Tag color="blue" className="value-tag">{s.preferIp as string}</Tag></dd>
+                  </div>
+                )}
+                {Boolean(s.debug) && (
+                  <div className="info-row">
+                    <dt>{t('pages.inbounds.form.mtgDebug')}</dt>
+                    <dd><Tag color="green" className="value-tag">{t('enabled')}</Tag></dd>
+                  </div>
+                )}
+              </>
+            );
+          })()}
           {links.length > 0 && (
             <div className="info-row">
               <dt>{t('pages.inbounds.copyLink')}</dt>

+ 1 - 1
frontend/src/schemas/protocols/inbound/hysteria.ts

@@ -10,7 +10,7 @@ export const HysteriaClientSchema = z.object({
   totalGB: z.number().int().min(0).default(0),
   expiryTime: z.number().int().default(0),
   enable: z.boolean().default(true),
-  tgId: z.number().int().default(0),
+  tgId: z.union([z.number(), z.string()]).transform((v) => Number(v) || 0).default(0),
   subId: z.string().default(''),
   comment: z.string().default(''),
   reset: z.number().int().min(0).default(0),

+ 16 - 0
frontend/src/schemas/protocols/inbound/mtproto.ts

@@ -1,10 +1,26 @@
 import { z } from 'zod';
 
+// mtg's [domain-fronting] section: where the sidecar forwards non-Telegram
+// traffic (e.g. an NGINX fake site). All optional — omitted keys fall back to
+// mtg's defaults (DNS-resolve the FakeTLS host, port 443, no proxy protocol).
+export const MtprotoDomainFrontingSchema = z.object({
+  ip: z.string().optional(),
+  port: z.number().int().min(0).max(65535).optional(),
+  proxyProtocol: z.boolean().optional(),
+});
+export type MtprotoDomainFronting = z.infer<typeof MtprotoDomainFrontingSchema>;
+
 // MTProto (Telegram) inbound. Served by an mtg sidecar process, not Xray, so
 // it has no clients and no stream settings. `secret` is the FakeTLS secret
 // (ee-prefixed); the backend rebuilds it to match `fakeTlsDomain` on save.
+// The remaining fields map to optional mtg config knobs and are written to the
+// generated mtg.toml only when set.
 export const MtprotoInboundSettingsSchema = z.object({
   fakeTlsDomain: z.string().default('www.cloudflare.com'),
   secret: z.string().default(''),
+  proxyProtocolListener: z.boolean().optional(),
+  preferIp: z.enum(['prefer-ipv6', 'prefer-ipv4', 'only-ipv6', 'only-ipv4']).optional(),
+  debug: z.boolean().optional(),
+  domainFronting: MtprotoDomainFrontingSchema.optional(),
 });
 export type MtprotoInboundSettings = z.infer<typeof MtprotoInboundSettingsSchema>;

+ 1 - 1
frontend/src/schemas/protocols/inbound/shadowsocks.ts

@@ -17,7 +17,7 @@ export const ShadowsocksClientSchema = z.object({
   totalGB: z.number().int().min(0).default(0),
   expiryTime: z.number().int().default(0),
   enable: z.boolean().default(true),
-  tgId: z.number().int().default(0),
+  tgId: z.union([z.number(), z.string()]).transform((v) => Number(v) || 0).default(0),
   subId: z.string().default(''),
   comment: z.string().default(''),
   reset: z.number().int().min(0).default(0),

+ 1 - 1
frontend/src/schemas/protocols/inbound/trojan.ts

@@ -16,7 +16,7 @@ export const TrojanClientSchema = z.object({
   totalGB: z.number().int().min(0).default(0),
   expiryTime: z.number().int().default(0),
   enable: z.boolean().default(true),
-  tgId: z.number().int().default(0),
+  tgId: z.union([z.number(), z.string()]).transform((v) => Number(v) || 0).default(0),
   subId: z.string().default(''),
   comment: z.string().default(''),
   reset: z.number().int().min(0).default(0),

+ 2 - 2
frontend/src/schemas/protocols/inbound/vless.ts

@@ -12,14 +12,14 @@ export const VlessFallbackSchema = z.object({
 export type VlessFallback = z.infer<typeof VlessFallbackSchema>;
 
 export const VlessClientSchema = z.object({
-  id: z.uuid(),
+  id: z.string().min(1),
   email: z.string().min(1),
   flow: FlowSchema.default(''),
   limitIp: z.number().int().min(0).default(0),
   totalGB: z.number().int().min(0).default(0),
   expiryTime: z.number().int().default(0),
   enable: z.boolean().default(true),
-  tgId: z.number().int().default(0),
+  tgId: z.union([z.number(), z.string()]).transform((v) => Number(v) || 0).default(0),
   subId: z.string().default(''),
   comment: z.string().default(''),
   reset: z.number().int().min(0).default(0),

+ 3 - 2
frontend/src/schemas/protocols/inbound/vmess.ts

@@ -3,14 +3,15 @@ import { z } from 'zod';
 import { VmessSecuritySchema } from '../shared/vmess';
 
 export const VmessClientSchema = z.object({
-  id: z.uuid(),
+  id: z.string().min(1),
   security: VmessSecuritySchema.default('auto'),
+  alterId: z.number().int().min(0).default(0),
   email: z.string().min(1),
   limitIp: z.number().int().min(0).default(0),
   totalGB: z.number().int().min(0).default(0),
   expiryTime: z.number().int().default(0),
   enable: z.boolean().default(true),
-  tgId: z.number().int().default(0),
+  tgId: z.union([z.number(), z.string()]).transform((v) => Number(v) || 0).default(0),
   subId: z.string().default(''),
   comment: z.string().default(''),
   reset: z.number().int().min(0).default(0),

+ 1 - 0
frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap

@@ -129,6 +129,7 @@ exports[`createDefaultVlessClient > produces a Zod-valid client 1`] = `
 
 exports[`createDefaultVmessClient > produces a Zod-valid client 1`] = `
 {
+  "alterId": 0,
   "comment": "",
   "email": "[email protected]",
   "enable": true,

+ 1 - 0
frontend/src/test/__snapshots__/inbound-full.test.ts.snap

@@ -505,6 +505,7 @@ exports[`InboundSchema (full) fixtures > parses vmess-tcp-tls byte-stably 1`] =
   "settings": {
     "clients": [
       {
+        "alterId": 0,
         "comment": "",
         "email": "[email protected]",
         "enable": true,

+ 168 - 0
frontend/src/test/__snapshots__/protocol-capabilities.test.ts.snap

@@ -672,6 +672,174 @@ exports[`protocol capability predicates > mtproto-basic :: xhttp/tls 1`] = `
 }
 `;
 
+exports[`protocol capability predicates > mtproto-domain-fronting :: grpc/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-domain-fronting :: grpc/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-domain-fronting :: grpc/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-domain-fronting :: httpupgrade/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-domain-fronting :: httpupgrade/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-domain-fronting :: kcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-domain-fronting :: tcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-domain-fronting :: tcp/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-domain-fronting :: tcp/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-domain-fronting :: ws/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-domain-fronting :: ws/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-domain-fronting :: xhttp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-domain-fronting :: xhttp/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-domain-fronting :: xhttp/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
 exports[`protocol capability predicates > shadowsocks-2022 :: grpc/none 1`] = `
 {
   "canEnableReality": false,

+ 19 - 0
frontend/src/test/__snapshots__/protocols.test.ts.snap

@@ -69,6 +69,24 @@ exports[`InboundSettingsSchema fixtures > parses mtproto-basic byte-stably 1`] =
 }
 `;
 
+exports[`InboundSettingsSchema fixtures > parses mtproto-domain-fronting byte-stably 1`] = `
+{
+  "protocol": "mtproto",
+  "settings": {
+    "debug": true,
+    "domainFronting": {
+      "ip": "127.0.0.1",
+      "port": 9443,
+      "proxyProtocol": true,
+    },
+    "fakeTlsDomain": "www.cloudflare.com",
+    "preferIp": "prefer-ipv4",
+    "proxyProtocolListener": true,
+    "secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d",
+  },
+}
+`;
+
 exports[`InboundSettingsSchema fixtures > parses shadowsocks-2022 byte-stably 1`] = `
 {
   "protocol": "shadowsocks",
@@ -167,6 +185,7 @@ exports[`InboundSettingsSchema fixtures > parses vmess-basic byte-stably 1`] = `
   "settings": {
     "clients": [
       {
+        "alterId": 0,
         "comment": "primary tester",
         "email": "[email protected]",
         "enable": true,

+ 15 - 0
frontend/src/test/golden/fixtures/inbound/mtproto-domain-fronting.json

@@ -0,0 +1,15 @@
+{
+  "protocol": "mtproto",
+  "settings": {
+    "fakeTlsDomain": "www.cloudflare.com",
+    "secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d",
+    "proxyProtocolListener": true,
+    "preferIp": "prefer-ipv4",
+    "debug": true,
+    "domainFronting": {
+      "ip": "127.0.0.1",
+      "port": 9443,
+      "proxyProtocol": true
+    }
+  }
+}

+ 11 - 0
frontend/src/test/inbound-form-adapter.test.ts

@@ -142,6 +142,17 @@ describe('formValuesToWirePayload', () => {
     expect(payload.streamSettings).toBe('');
   });
 
+  it('emits empty sniffing for mtproto (mtg-served, not Xray)', () => {
+    const values = rawInboundToFormValues({
+      ...vlessRow,
+      protocol: 'mtproto',
+      settings: { fakeTlsDomain: 'www.cloudflare.com', secret: 'ee00' },
+    });
+    const payload = formValuesToWirePayload(values);
+    expect(payload.protocol).toBe('mtproto');
+    expect(payload.sniffing).toBe('');
+  });
+
   it('omits nodeId when null', () => {
     const values = rawInboundToFormValues({ ...vlessRow, nodeId: null });
     const payload = formValuesToWirePayload(values);

+ 25 - 32
frontend/src/utils/index.ts

@@ -576,49 +576,42 @@ export class ClipboardManager {
   }
 
   static _legacyCopy(text: string): boolean {
-    const textarea = document.createElement('textarea');
-    textarea.value = text;
-    textarea.setAttribute('readonly', '');
-    textarea.setAttribute('aria-hidden', 'true');
-    textarea.style.position = 'absolute';
-    textarea.style.left = '-9999px';
-    textarea.style.top = '0';
-    textarea.style.opacity = '1';
-
-    const active = document.activeElement as HTMLElement | null;
-    const host = (active && active !== document.body && active.parentElement)
-      ? active.parentElement
-      : document.body;
-    host.appendChild(textarea);
-
-    const sel0 = document.getSelection();
-    const prevSelection = sel0 && sel0.rangeCount ? sel0.getRangeAt(0) : null;
+    const span = document.createElement('span');
+    span.textContent = text;
+    span.style.whiteSpace = 'pre';
+    span.style.position = 'absolute';
+    span.style.left = '-9999px';
+    span.style.top = '0';
+
+    document.body.appendChild(span);
+
+    const selection = window.getSelection();
+    if (!selection) {
+      document.body.removeChild(span);
+      return false;
+    }
+
+    const prevSelection = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
+
+    selection.removeAllRanges();
+    const range = window.document.createRange();
+    range.selectNodeContents(span);
+    selection.addRange(range);
 
     let ok = false;
     try {
-      textarea.focus({ preventScroll: true });
-      textarea.select();
-      textarea.setSelectionRange(0, text.length);
-      // Routed through a dynamic lookup so the @deprecated tag on
-      // Document.execCommand doesn't surface here. execCommand is the
-      // only copy path that works in insecure contexts (HTTP panels
-      // behind IP/localhost) — reached only after navigator.clipboard
-      // fails or is unavailable.
       const exec = (document as unknown as Record<string, unknown>)['execCommand'];
       if (typeof exec === 'function') {
         ok = (exec as (cmd: string) => boolean).call(document, 'copy');
       }
     } catch {}
 
-    host.removeChild(textarea);
-    if (active && typeof active.focus === 'function') {
-      try { active.focus({ preventScroll: true }); } catch {}
-    }
+    selection.removeAllRanges();
     if (prevSelection) {
-      const sel = document.getSelection();
-      sel?.removeAllRanges();
-      sel?.addRange(prevSelection);
+      selection.addRange(prevSelection);
     }
+
+    document.body.removeChild(span);
     return ok;
   }
 }

+ 77 - 12
mtproto/manager.go

@@ -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
mtproto/manager_test.go

@@ -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)
+		}
+	}
+}

+ 6 - 4
util/link/outbound.go

@@ -552,12 +552,13 @@ func buildStream(network, security string) map[string]any {
 	default:
 		stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
 	}
-	if security == "tls" {
+	switch security {
+	case "tls":
 		stream["tlsSettings"] = map[string]any{
 			"serverName": "", "alpn": []any{}, "fingerprint": "",
 			"echConfigList": "", "verifyPeerCertByName": "", "pinnedPeerCertSha256": "",
 		}
-	} else if security == "reality" {
+	case "reality":
 		stream["realitySettings"] = map[string]any{
 			"publicKey": "", "fingerprint": "chrome", "serverName": "",
 			"shortId": "", "spiderX": "", "mldsa65Verify": "",
@@ -624,7 +625,8 @@ func applyTransport(stream map[string]any, p url.Values) {
 
 func applySecurity(stream map[string]any, p url.Values) {
 	sec := stream["security"].(string)
-	if sec == "tls" {
+	switch sec {
+	case "tls":
 		tls := stream["tlsSettings"].(map[string]any)
 		tls["serverName"] = p.Get("sni")
 		tls["fingerprint"] = p.Get("fp")
@@ -633,7 +635,7 @@ func applySecurity(stream map[string]any, p url.Values) {
 		}
 		tls["echConfigList"] = p.Get("ech")
 		tls["pinnedPeerCertSha256"] = p.Get("pcs")
-	} else if sec == "reality" {
+	case "reality":
 		re := stream["realitySettings"].(map[string]any)
 		re["serverName"] = p.Get("sni")
 		re["fingerprint"] = firstNonEmpty(p.Get("fp"), "chrome")

+ 1 - 1
util/link/outbound_test.go

@@ -59,4 +59,4 @@ func TestSlugAndSuggest(t *testing.T) {
 	if tag != "hk-sg-01" {
 		t.Errorf("suggest tag got %q", tag)
 	}
-}
+}

+ 1 - 1
web/job/outbound_subscription_job.go

@@ -45,4 +45,4 @@ func (j *OutboundSubscriptionJob) Run() {
 		// view (new outbounds will be visible after the reload cycle).
 		websocket.BroadcastInvalidate(websocket.MessageTypeOutbounds)
 	}
-}
+}

+ 39 - 2
web/service/client.go

@@ -779,6 +779,20 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 		updated.CreatedAt = existing.CreatedAt
 	}
 
+	// Preserve existing credentials when the caller omits them, so a partial
+	// update (e.g. only changing traffic/expiry) doesn't silently rotate the
+	// client's UUID/password/auth via fillProtocolDefaults. Supplying a new
+	// value still rotates it intentionally.
+	if updated.ID == "" {
+		updated.ID = existing.UUID
+	}
+	if updated.Password == "" {
+		updated.Password = existing.Password
+	}
+	if updated.Auth == "" {
+		updated.Auth = existing.Auth
+	}
+
 	if updated.Email != existing.Email {
 		var collisionCount int64
 		if err := database.GetDB().Model(&model.ClientRecord{}).
@@ -1479,13 +1493,27 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st
 	if err != nil {
 		return false, err
 	}
+
+	needRestart := false
+	if !rec.Enable {
+		updated := rec.ToClient()
+		updated.Enable = true
+		nr, uErr := s.Update(inboundSvc, rec.Id, *updated)
+		if uErr != nil {
+			logger.Warning("Failed to auto-enable client during traffic reset:", uErr)
+		}
+		if nr {
+			needRestart = true
+		}
+	}
+
 	if len(inboundIds) == 0 {
 		if rErr := inboundSvc.ResetClientTrafficByEmail(email); rErr != nil {
 			return false, rErr
 		}
-		return false, nil
+		return needRestart, nil
 	}
-	needRestart := false
+
 	for _, ibId := range inboundIds {
 		nr, rErr := inboundSvc.ResetClientTraffic(ibId, email)
 		if rErr != nil {
@@ -1795,6 +1823,15 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
 		return 0, nil
 	}
 
+	for _, e := range cleanEmails {
+		rec, err := s.GetRecordByEmail(nil, e)
+		if err == nil && !rec.Enable {
+			updated := rec.ToClient()
+			updated.Enable = true
+			s.Update(inboundSvc, rec.Id, *updated)
+		}
+	}
+
 	affected := 0
 	err := submitTrafficWrite(func() error {
 		db := database.GetDB()

+ 20 - 2
web/service/outbound.go

@@ -352,8 +352,14 @@ func (s *OutboundService) testOutboundHTTP(outboundJSON string, testURL string,
 			return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid allOutbounds JSON: %v", err)}, nil
 		}
 	}
-	if len(allOutbounds) == 0 {
-		allOutbounds = []any{testOutbound}
+	// The outbound under test must be present in the config so burstObservatory
+	// has something with outboundTag to probe. allOutbounds is the template's
+	// outbounds (for dialerProxy chains); subscription outbounds are injected at
+	// runtime and aren't part of it, so without this the probe targets a tag that
+	// doesn't exist in the config and every test times out. Append (don't replace)
+	// so manual outbounds' dialerProxy chains keep resolving.
+	if !outboundsContainTag(allOutbounds, outboundTag) {
+		allOutbounds = append(allOutbounds, testOutbound)
 	}
 
 	metricsPort, err := findAvailablePort()
@@ -396,6 +402,18 @@ func (s *OutboundService) testOutboundHTTP(outboundJSON string, testURL string,
 	return pollObservatoryResult(testProcess, metricsPort, outboundTag, 12*time.Second), nil
 }
 
+// outboundsContainTag reports whether any outbound in the slice has the given tag.
+func outboundsContainTag(outbounds []any, tag string) bool {
+	for _, ob := range outbounds {
+		if m, ok := ob.(map[string]any); ok {
+			if t, _ := m["tag"].(string); t == tag {
+				return true
+			}
+		}
+	}
+	return false
+}
+
 // createTestConfig builds a probe-only xray config: the original outbounds
 // are kept as-is so dialerProxy chains still resolve, a burstObservatory
 // is wired to probe the target tag, and a metrics listener exposes the

+ 1 - 1
web/service/outbound_subscription.go

@@ -537,4 +537,4 @@ Consequences for balancers / routing:
 
 We deliberately do *not* mutate the saved xrayTemplateConfig. Subscription
 outbounds are always injected at runtime in GetXrayConfig.
-*/
+*/

+ 26 - 0
web/service/outbound_subscription_test.go

@@ -85,6 +85,32 @@ func TestAssignStableTags(t *testing.T) {
 	})
 }
 
+// TestOutboundsContainTag covers the guard that ensures the outbound under test
+// is present in the HTTP-probe config. Subscription outbounds aren't part of the
+// template outbounds the frontend sends as allOutbounds, so the probe must append
+// the tested outbound when its tag is missing (otherwise burstObservatory has
+// nothing to probe and every subscription test times out).
+func TestOutboundsContainTag(t *testing.T) {
+	template := []any{
+		map[string]any{"tag": "direct", "protocol": "freedom"},
+		map[string]any{"tag": "blocked", "protocol": "blackhole"},
+	}
+	if !outboundsContainTag(template, "direct") {
+		t.Fatal("expected tag 'direct' to be found")
+	}
+	if outboundsContainTag(template, "sub1-tokyo") {
+		t.Fatal("expected subscription tag to be absent from template outbounds")
+	}
+	if outboundsContainTag(nil, "anything") {
+		t.Fatal("expected empty slice to contain no tags")
+	}
+	// Tolerates non-map / untagged entries without panicking.
+	mixed := []any{"not-a-map", map[string]any{"protocol": "freedom"}}
+	if outboundsContainTag(mixed, "direct") {
+		t.Fatal("expected no match among untagged/non-map entries")
+	}
+}
+
 // TestSanitizePublicHTTPURLRejectsPrivateAndBadSchemes covers the SSRF guard used
 // when fetching subscription URLs. All rejected cases use literal IPs or bad
 // schemes so the test never performs real DNS resolution.

+ 7 - 1
web/translation/ar-EG.json

@@ -503,7 +503,13 @@
         "encryptionMethod": "طريقة التشفير",
         "fakeTlsDomain": "نطاق FakeTLS (SNI)",
         "mtprotoSecret": "المفتاح السري",
-        "mtprotoHint": "يتم تقديم MTProto عبر عملية mtg منفصلة وليس Xray. إعدادات النقل والعملاء لا تنطبق هنا — شارك الرابط أدناه مع تيليجرام.",
+        "mtgDomainFrontingIp": "عنوان IP لـ Domain fronting",
+        "mtgDomainFrontingPort": "منفذ Domain fronting",
+        "mtgDomainFrontingProxyProtocol": "بروتوكول PROXY لـ Domain fronting",
+        "mtgDomainFrontingHint": "المكان الذي يرسل إليه mtg حركة المرور غير الخاصة بتيليجرام — مثل موقع NGINX الوهمي لديك. اترك حقل IP فارغًا لاستخدام نطاق FakeTLS عبر DNS؛ المنفذ الافتراضي هو 443.",
+        "mtgProxyProtocolListener": "قبول بروتوكول PROXY (المستمع)",
+        "mtgPreferIp": "تفضيل IP",
+        "mtgDebug": "سجل التصحيح",
         "visionTestseed": "Vision testseed",
         "version": "الإصدار",
         "udpIdleTimeout": "UDP idle timeout (ثانية)",

+ 7 - 1
web/translation/en-US.json

@@ -504,7 +504,13 @@
         "encryptionMethod": "Encryption method",
         "fakeTlsDomain": "FakeTLS domain (SNI)",
         "mtprotoSecret": "Secret",
-        "mtprotoHint": "MTProto is served by a separate mtg process, not Xray. Stream settings and clients do not apply here — share the link below with Telegram.",
+        "mtgDomainFrontingIp": "Domain fronting IP",
+        "mtgDomainFrontingPort": "Domain fronting port",
+        "mtgDomainFrontingProxyProtocol": "Domain fronting PROXY protocol",
+        "mtgDomainFrontingHint": "Where mtg sends non-Telegram traffic — e.g. your NGINX fake site. Leave the IP empty to use the FakeTLS domain via DNS; default port is 443.",
+        "mtgProxyProtocolListener": "Accept PROXY protocol (listener)",
+        "mtgPreferIp": "IP preference",
+        "mtgDebug": "Debug logging",
         "visionTestseed": "Vision testseed",
         "version": "Version",
         "udpIdleTimeout": "UDP idle timeout (s)",

+ 7 - 1
web/translation/es-ES.json

@@ -503,7 +503,13 @@
         "encryptionMethod": "Método de cifrado",
         "fakeTlsDomain": "Dominio FakeTLS (SNI)",
         "mtprotoSecret": "Secreto",
-        "mtprotoHint": "MTProto se sirve mediante un proceso mtg independiente, no Xray. Los ajustes de transporte y los clientes no aplican aquí; comparte el enlace de abajo con Telegram.",
+        "mtgDomainFrontingIp": "IP de domain fronting",
+        "mtgDomainFrontingPort": "Puerto de domain fronting",
+        "mtgDomainFrontingProxyProtocol": "Protocolo PROXY de domain fronting",
+        "mtgDomainFrontingHint": "Adónde envía mtg el tráfico que no es de Telegram, p. ej. tu sitio web falso de NGINX. Deja la IP vacía para usar el dominio FakeTLS mediante DNS; el puerto predeterminado es 443.",
+        "mtgProxyProtocolListener": "Aceptar protocolo PROXY (escucha)",
+        "mtgPreferIp": "Preferencia de IP",
+        "mtgDebug": "Registro de depuración",
         "visionTestseed": "Vision testseed",
         "version": "Versión",
         "udpIdleTimeout": "UDP idle timeout (s)",

+ 7 - 1
web/translation/fa-IR.json

@@ -503,7 +503,13 @@
         "encryptionMethod": "روش رمزنگاری",
         "fakeTlsDomain": "دامنه FakeTLS (SNI)",
         "mtprotoSecret": "کلید مخفی",
-        "mtprotoHint": "پروتکل MTProto توسط یک پردازش جداگانه mtg ارائه می‌شود، نه Xray. تنظیمات انتقال و کلاینت‌ها اینجا کاربرد ندارند — لینک زیر را با تلگرام به اشتراک بگذارید.",
+        "mtgDomainFrontingIp": "آی‌پی Domain fronting",
+        "mtgDomainFrontingPort": "پورت Domain fronting",
+        "mtgDomainFrontingProxyProtocol": "پروتکل PROXY برای Domain fronting",
+        "mtgDomainFrontingHint": "جایی که mtg ترافیک غیرتلگرامی را به آن ارسال می‌کند — مثلاً سایت جعلی NGINX شما. برای استفاده از دامنهٔ FakeTLS از طریق DNS، فیلد IP را خالی بگذارید؛ پورت پیش‌فرض 443 است.",
+        "mtgProxyProtocolListener": "پذیرش پروتکل PROXY (شنونده)",
+        "mtgPreferIp": "ترجیح IP",
+        "mtgDebug": "گزارش اشکال‌زدایی",
         "visionTestseed": "Vision testseed",
         "version": "نسخه",
         "udpIdleTimeout": "UDP idle timeout (s)",

+ 7 - 1
web/translation/id-ID.json

@@ -503,7 +503,13 @@
         "encryptionMethod": "Metode enkripsi",
         "fakeTlsDomain": "Domain FakeTLS (SNI)",
         "mtprotoSecret": "Secret",
-        "mtprotoHint": "MTProto dijalankan oleh proses mtg terpisah, bukan Xray. Pengaturan stream dan klien tidak berlaku di sini — bagikan tautan di bawah ke Telegram.",
+        "mtgDomainFrontingIp": "IP domain fronting",
+        "mtgDomainFrontingPort": "Port domain fronting",
+        "mtgDomainFrontingProxyProtocol": "Protokol PROXY domain fronting",
+        "mtgDomainFrontingHint": "Tujuan mtg mengirim lalu lintas non-Telegram — mis. situs palsu NGINX Anda. Kosongkan IP untuk memakai domain FakeTLS melalui DNS; port bawaan adalah 443.",
+        "mtgProxyProtocolListener": "Terima protokol PROXY (listener)",
+        "mtgPreferIp": "Preferensi IP",
+        "mtgDebug": "Log debug",
         "visionTestseed": "Vision testseed",
         "version": "Versi",
         "udpIdleTimeout": "UDP idle timeout (d)",

+ 7 - 1
web/translation/ja-JP.json

@@ -503,7 +503,13 @@
         "encryptionMethod": "暗号化方式",
         "fakeTlsDomain": "FakeTLS ドメイン (SNI)",
         "mtprotoSecret": "シークレット",
-        "mtprotoHint": "MTProto は Xray ではなく独立した mtg プロセスで提供されます。ストリーム設定とクライアントはここでは適用されません。下のリンクを Telegram で共有してください。",
+        "mtgDomainFrontingIp": "ドメインフロンティング IP",
+        "mtgDomainFrontingPort": "ドメインフロンティング ポート",
+        "mtgDomainFrontingProxyProtocol": "ドメインフロンティング PROXY プロトコル",
+        "mtgDomainFrontingHint": "mtg が Telegram 以外のトラフィックを転送する先 — 例: あなたの NGINX ダミーサイト。IP を空欄にすると DNS 経由で FakeTLS ドメインを使用します。デフォルトポートは 443 です。",
+        "mtgProxyProtocolListener": "PROXY プロトコルを受け入れる(リスナー)",
+        "mtgPreferIp": "IP の優先設定",
+        "mtgDebug": "デバッグログ",
         "visionTestseed": "Vision testseed",
         "version": "バージョン",
         "udpIdleTimeout": "UDP idle timeout (秒)",

+ 7 - 1
web/translation/pt-BR.json

@@ -503,7 +503,13 @@
         "encryptionMethod": "Método de criptografia",
         "fakeTlsDomain": "Domínio FakeTLS (SNI)",
         "mtprotoSecret": "Segredo",
-        "mtprotoHint": "O MTProto é servido por um processo mtg separado, não pelo Xray. As configurações de transporte e os clientes não se aplicam aqui — compartilhe o link abaixo com o Telegram.",
+        "mtgDomainFrontingIp": "IP de domain fronting",
+        "mtgDomainFrontingPort": "Porta de domain fronting",
+        "mtgDomainFrontingProxyProtocol": "Protocolo PROXY de domain fronting",
+        "mtgDomainFrontingHint": "Para onde o mtg envia o tráfego que não é do Telegram — por exemplo, seu site falso NGINX. Deixe o IP vazio para usar o domínio FakeTLS via DNS; a porta padrão é 443.",
+        "mtgProxyProtocolListener": "Aceitar protocolo PROXY (listener)",
+        "mtgPreferIp": "Preferência de IP",
+        "mtgDebug": "Log de depuração",
         "visionTestseed": "Vision testseed",
         "version": "Versão",
         "udpIdleTimeout": "UDP idle timeout (s)",

+ 7 - 1
web/translation/ru-RU.json

@@ -503,7 +503,13 @@
         "encryptionMethod": "Метод шифрования",
         "fakeTlsDomain": "Домен FakeTLS (SNI)",
         "mtprotoSecret": "Секрет",
-        "mtprotoHint": "MTProto обслуживается отдельным процессом mtg, а не Xray. Настройки транспорта и клиенты здесь не применяются — поделитесь ссылкой ниже в Telegram.",
+        "mtgDomainFrontingIp": "IP домен-фронтинга",
+        "mtgDomainFrontingPort": "Порт домен-фронтинга",
+        "mtgDomainFrontingProxyProtocol": "PROXY-протокол домен-фронтинга",
+        "mtgDomainFrontingHint": "Куда mtg отправляет не-Telegram трафик — например, на ваш фейковый сайт NGINX. Оставьте IP пустым, чтобы использовать домен FakeTLS через DNS; порт по умолчанию — 443.",
+        "mtgProxyProtocolListener": "Принимать PROXY-протокол (слушатель)",
+        "mtgPreferIp": "Предпочтение IP",
+        "mtgDebug": "Журнал отладки",
         "visionTestseed": "Vision testseed",
         "version": "Версия",
         "udpIdleTimeout": "UDP idle timeout (с)",

+ 7 - 1
web/translation/tr-TR.json

@@ -504,7 +504,13 @@
         "encryptionMethod": "Şifreleme Yöntemi",
         "fakeTlsDomain": "FakeTLS alan adı (SNI)",
         "mtprotoSecret": "Gizli Anahtar (Secret)",
-        "mtprotoHint": "MTProto, Xray tarafından değil, ayrı bir mtg işlemi tarafından sunulur. Akış ayarları ve kullanıcılar burada geçerli değildir — aşağıdaki bağlantıyı Telegram ile paylaşın.",
+        "mtgDomainFrontingIp": "Domain fronting IP",
+        "mtgDomainFrontingPort": "Domain fronting portu",
+        "mtgDomainFrontingProxyProtocol": "Domain fronting PROXY protokolü",
+        "mtgDomainFrontingHint": "mtg'nin Telegram dışı trafiği gönderdiği yer — örn. NGINX sahte siteniz. FakeTLS alan adını DNS üzerinden kullanmak için IP'yi boş bırakın; varsayılan port 443'tür.",
+        "mtgProxyProtocolListener": "PROXY protokolünü kabul et (dinleyici)",
+        "mtgPreferIp": "IP tercihi",
+        "mtgDebug": "Hata ayıklama günlüğü",
         "visionTestseed": "Vision Testseed",
         "version": "Sürüm",
         "udpIdleTimeout": "UDP Idle Timeout (s)",

+ 7 - 1
web/translation/uk-UA.json

@@ -503,7 +503,13 @@
         "encryptionMethod": "Метод шифрування",
         "fakeTlsDomain": "Домен FakeTLS (SNI)",
         "mtprotoSecret": "Секрет",
-        "mtprotoHint": "MTProto обслуговується окремим процесом mtg, а не Xray. Налаштування транспорту та клієнти тут не застосовуються — поділіться посиланням нижче в Telegram.",
+        "mtgDomainFrontingIp": "IP домен-фронтингу",
+        "mtgDomainFrontingPort": "Порт домен-фронтингу",
+        "mtgDomainFrontingProxyProtocol": "PROXY-протокол домен-фронтингу",
+        "mtgDomainFrontingHint": "Куди mtg надсилає не-Telegram трафік — наприклад, на ваш фейковий сайт NGINX. Залиште IP порожнім, щоб використовувати домен FakeTLS через DNS; типовий порт — 443.",
+        "mtgProxyProtocolListener": "Приймати PROXY-протокол (слухач)",
+        "mtgPreferIp": "Перевага IP",
+        "mtgDebug": "Журнал налагодження",
         "visionTestseed": "Vision testseed",
         "version": "Версія",
         "udpIdleTimeout": "UDP idle timeout (с)",

+ 7 - 1
web/translation/vi-VN.json

@@ -503,7 +503,13 @@
         "encryptionMethod": "Phương thức mã hóa",
         "fakeTlsDomain": "Tên miền FakeTLS (SNI)",
         "mtprotoSecret": "Khóa bí mật",
-        "mtprotoHint": "MTProto được phục vụ bởi một tiến trình mtg riêng, không phải Xray. Cài đặt truyền tải và máy khách không áp dụng ở đây — hãy chia sẻ liên kết bên dưới với Telegram.",
+        "mtgDomainFrontingIp": "IP domain fronting",
+        "mtgDomainFrontingPort": "Cổng domain fronting",
+        "mtgDomainFrontingProxyProtocol": "Giao thức PROXY domain fronting",
+        "mtgDomainFrontingHint": "Nơi mtg gửi lưu lượng không phải Telegram — ví dụ trang web giả NGINX của bạn. Để trống IP để dùng tên miền FakeTLS qua DNS; cổng mặc định là 443.",
+        "mtgProxyProtocolListener": "Chấp nhận giao thức PROXY (trình lắng nghe)",
+        "mtgPreferIp": "Ưu tiên IP",
+        "mtgDebug": "Nhật ký gỡ lỗi",
         "visionTestseed": "Vision testseed",
         "version": "Phiên bản",
         "udpIdleTimeout": "UDP idle timeout (s)",

+ 7 - 1
web/translation/zh-CN.json

@@ -503,7 +503,13 @@
         "encryptionMethod": "加密方法",
         "fakeTlsDomain": "FakeTLS 域名 (SNI)",
         "mtprotoSecret": "密钥",
-        "mtprotoHint": "MTProto 由独立的 mtg 进程提供服务,而非 Xray。传输设置和客户端在此不适用——请将下方链接分享到 Telegram。",
+        "mtgDomainFrontingIp": "域前置 IP",
+        "mtgDomainFrontingPort": "域前置端口",
+        "mtgDomainFrontingProxyProtocol": "域前置 PROXY 协议",
+        "mtgDomainFrontingHint": "mtg 转发非 Telegram 流量的目标——例如你的 NGINX 伪装站点。留空 IP 则通过 DNS 解析 FakeTLS 域名;默认端口为 443。",
+        "mtgProxyProtocolListener": "接受 PROXY 协议(监听器)",
+        "mtgPreferIp": "IP 优先级",
+        "mtgDebug": "调试日志",
         "visionTestseed": "Vision testseed",
         "version": "版本",
         "udpIdleTimeout": "UDP 空闲超时 (s)",

+ 7 - 1
web/translation/zh-TW.json

@@ -503,7 +503,13 @@
         "encryptionMethod": "加密方法",
         "fakeTlsDomain": "FakeTLS 網域 (SNI)",
         "mtprotoSecret": "金鑰",
-        "mtprotoHint": "MTProto 由獨立的 mtg 程序提供服務,而非 Xray。傳輸設定與用戶端在此不適用——請將下方連結分享至 Telegram。",
+        "mtgDomainFrontingIp": "網域前置 IP",
+        "mtgDomainFrontingPort": "網域前置連接埠",
+        "mtgDomainFrontingProxyProtocol": "網域前置 PROXY 協定",
+        "mtgDomainFrontingHint": "mtg 轉發非 Telegram 流量的目標——例如你的 NGINX 偽裝站台。IP 留空則透過 DNS 解析 FakeTLS 網域;預設連接埠為 443。",
+        "mtgProxyProtocolListener": "接受 PROXY 協定(監聽器)",
+        "mtgPreferIp": "IP 偏好",
+        "mtgDebug": "除錯日誌",
         "visionTestseed": "Vision testseed",
         "version": "版本",
         "udpIdleTimeout": "UDP 閒置逾時 (s)",

+ 0 - 1
x-ui.sh

@@ -923,7 +923,6 @@ show_mtproto_status() {
             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() {