Browse Source

fix(mtproto): use hosts for public share links (#6369)

* fix(mtproto): use hosts for public share links

Generate MTProto subscription, client, copy, QR, and export links from managed Hosts so reverse-proxied public ports are advertised correctly. Migrate the redundant legacy custom share address into a Host and keep old imports compatible.

Closes #5126.

* fix(mtproto): keep host share links lossless and consistent

Address review on the MTProto hosts share-link change.

The migration no longer drops a legacy custom share address: an unrelated
(or disabled) Host stopped suppressing it, so only a Host already advertising
the same address does. An imported address now clears the same validation the
strict normalizer applies to every other protocol before it becomes a Host.

Panel and subscription agree on the endpoint a Host advertises: a portless host
string inherits the inbound port rather than the group's, and a port-only host
inherits the inbound address instead of emitting server=%3A8443.

LinksForClient prefers host endpoints for every protocol, the way getSubs and
inboundLinks already do, so the client-links API no longer ignores managed
hosts.

* fix(mtproto): migrate legacy share address past unusable hosts

The seeder skipped the conversion whenever any Host already carried the
address, including one that is disabled or excludes the raw sub type.
hostEndpoints drops those, so nothing advertised the address afterwards and
the marker committed with no way back. The duplicate check now mirrors that
same predicate.

UpdateInbound cleared a legacy MTProto shareAddr without the Host conversion
AddInbound runs, so re-applying an inbound definition through the API dropped
the public address silently. Both paths share one capture helper now.

Refresh the generated clients API reference for the summary reworded in the
previous commit.

* fix(inbounds): wait for the hosts list before building mtproto links

The page destructured only `hosts` from useHostsQuery, and that list reads
empty both while /panel/api/hosts/list is in flight and after it fails.
withMtprotoHostEndpoints then returns the inbound untouched, so Copy, QR and
Export advertise the internal listen port — the endpoint this branch exists to
replace. It is worse than not fixing it: the seeder has already moved a legacy
custom share address into a Host, so the fallback is the panel's own hostname
instead of the operator's address, and the Go generators reading the same rows
from the DB stay correct, so the two disagree for one inbound.

Fold the query into the page's existing readiness gate, the same way
useInbounds and HostsPage already consume that hook, so an empty list means
"no hosts" rather than "not loaded yet". The error branch fires only when
nothing is cached, so a refetch failing on window focus does not blank a page
whose host rows are still perfectly usable.

---------

Co-authored-by: Sanaei <[email protected]>
Amirmohammad Sadat Shokouhi 5 hours ago
parent
commit
5a63d5d468

+ 14 - 14
docs/content/docs/en/reference/api/clients.mdx

@@ -273,13 +273,13 @@ _openapi:
         clients.
       url: '#return-every-protocol-url-vless-vmess-trojan-ss-hysteria-hy2-for-clients-matching-the-subscription-id-same-result-set-as-the-configured-subpath-endpoint-but-as-a-json-array--no-base64-when-an-inbound-has-streamsettingsexternalproxy-set-one-url-is-emitted-per-external-proxy-empty-array-when-the-subid-has-no-enabled-clients'
     - depth: 2
-      title: 'Return every URL for one client across all attached inbounds — the same
-        strings the Copy URL button copies in the panel UI. Supported protocols:
-        vmess, vless, trojan, shadowsocks, hysteria. If
-        streamSettings.externalProxy is set, returns one URL per external proxy.
-        Protocols without a URL form (socks, http, mixed, wireguard, dokodemo,
-        tunnel) contribute nothing.'
-      url: '#return-every-url-for-one-client-across-all-attached-inbounds--the-same-strings-the-copy-url-button-copies-in-the-panel-ui-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-if-streamsettingsexternalproxy-is-set-returns-one-url-per-external-proxy-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing'
+      title: 'Return every URL for one client across all attached inbounds, one per
+        advertised endpoint: the managed hosts of the inbound, else its
+        streamSettings.externalProxy entries, else its own address. Supported
+        protocols: vmess, vless, trojan, shadowsocks, hysteria, mtproto. Protocols
+        without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel)
+        contribute nothing.'
+      url: '#return-every-url-for-one-client-across-all-attached-inbounds-one-per-advertised-endpoint-the-managed-hosts-of-the-inbound-else-its-streamsettingsexternalproxy-entries-else-its-own-address-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-mtproto-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing'
   structuredData:
     headings:
       - content: List every client with its attached inbound IDs and traffic record. The
@@ -508,13 +508,13 @@ _openapi:
           URL is emitted per external proxy. Empty array when the subId has no
           enabled clients.
         id: return-every-protocol-url-vless-vmess-trojan-ss-hysteria-hy2-for-clients-matching-the-subscription-id-same-result-set-as-the-configured-subpath-endpoint-but-as-a-json-array--no-base64-when-an-inbound-has-streamsettingsexternalproxy-set-one-url-is-emitted-per-external-proxy-empty-array-when-the-subid-has-no-enabled-clients
-      - content: 'Return every URL for one client across all attached inbounds — the
-          same strings the Copy URL button copies in the panel UI. Supported
-          protocols: vmess, vless, trojan, shadowsocks, hysteria. If
-          streamSettings.externalProxy is set, returns one URL per external
-          proxy. Protocols without a URL form (socks, http, mixed, wireguard,
-          dokodemo, tunnel) contribute nothing.'
-        id: return-every-url-for-one-client-across-all-attached-inbounds--the-same-strings-the-copy-url-button-copies-in-the-panel-ui-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-if-streamsettingsexternalproxy-is-set-returns-one-url-per-external-proxy-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing
+      - content: 'Return every URL for one client across all attached inbounds, one
+          per advertised endpoint: the managed hosts of the inbound, else its
+          streamSettings.externalProxy entries, else its own address. Supported
+          protocols: vmess, vless, trojan, shadowsocks, hysteria, mtproto. Protocols
+          without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel)
+          contribute nothing.'
+        id: return-every-url-for-one-client-across-all-attached-inbounds-one-per-advertised-endpoint-the-managed-hosts-of-the-inbound-else-its-streamsettingsexternalproxy-entries-else-its-own-address-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-mtproto-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing
     contents:
       - content: >-
           Fields the server fills in when they are omitted — a valid value sent

+ 1 - 1
docs/public/openapi.json

@@ -9705,7 +9705,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Return every URL for one client across all attached inbounds — the same strings the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
+        "summary": "Return every URL for one client across all attached inbounds, one per advertised endpoint: the managed hosts of the inbound, else its streamSettings.externalProxy entries, else its own address. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, mtproto. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
         "operationId": "get_panel_api_clients_links_email",
         "parameters": [
           {

+ 1 - 1
frontend/public/openapi.json

@@ -9705,7 +9705,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Return every URL for one client across all attached inbounds — the same strings the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
+        "summary": "Return every URL for one client across all attached inbounds, one per advertised endpoint: the managed hosts of the inbound, else its streamSettings.externalProxy entries, else its own address. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, mtproto. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
         "operationId": "get_panel_api_clients_links_email",
         "parameters": [
           {

+ 50 - 1
frontend/src/lib/hosts/host-link.ts

@@ -1,5 +1,7 @@
 import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-proxy';
-import type { HostFormValues } from '@/schemas/api/host';
+import type { HostFormValues, HostRecord } from '@/schemas/api/host';
+import type { Inbound } from '@/schemas/api/inbound';
+import { resolveAddr } from '@/lib/xray/inbound-link';
 
 // The subset of a host that affects its share link. Mirrors the fields the
 // backend's hostToExternalProxyMap reads.
@@ -54,3 +56,50 @@ export function hostToExternalProxyEntry(host: HostLinkInput): ExternalProxyEntr
     vlessRoute: host.vlessRoute || undefined,
   };
 }
+
+function splitAdvertisedHost(value: string, inboundPort: number): [string, number] {
+  const host = value.trim();
+  if (host.startsWith('[')) {
+    const close = host.indexOf(']');
+    if (close > 0) {
+      const port = host.slice(close + 1).match(/^:(\d+)$/)?.[1];
+      return [host.slice(1, close), port ? Number(port) : inboundPort];
+    }
+  }
+  const match = host.match(/^([^:]*):(\d+)$/);
+  return match ? [match[1], Number(match[2])] : [host, inboundPort];
+}
+
+export function withMtprotoHostEndpoints(
+  inbound: Inbound,
+  inboundId: number,
+  records: HostRecord[],
+  hostOverride: string,
+  fallbackHostname: string,
+): Inbound {
+  if (inbound.protocol !== 'mtproto') return inbound;
+  const endpoints: ExternalProxyEntry[] = [];
+  for (const record of records) {
+    if (
+      record.isDisabled ||
+      !record.inboundIds.includes(inboundId) ||
+      record.excludeFromSubTypes?.includes('raw')
+    ) {
+      continue;
+    }
+    for (const value of record.hosts) {
+      const [dest, port] = splitAdvertisedHost(value, inbound.port);
+      endpoints.push({
+        forceTls: 'same',
+        dest: dest || resolveAddr(inbound, hostOverride, fallbackHostname),
+        port,
+        remark: record.remark || '',
+      });
+    }
+  }
+  if (endpoints.length === 0) return inbound;
+  return {
+    ...inbound,
+    streamSettings: { ...inbound.streamSettings, externalProxy: endpoints },
+  } as Inbound;
+}

+ 1 - 1
frontend/src/pages/api-docs/endpoints.ts

@@ -1561,7 +1561,7 @@ export const sections: readonly Section[] = [
         method: 'GET',
         path: '/panel/api/clients/links/:email',
         summary:
-          'Return every URL for one client across all attached inbounds — the same strings the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.',
+          'Return every URL for one client across all attached inbounds, one per advertised endpoint: the managed hosts of the inbound, else its streamSettings.externalProxy entries, else its own address. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, mtproto. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.',
         params: [
           { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
         ],

+ 42 - 9
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -38,6 +38,8 @@ import { useTheme } from '@/hooks/useTheme';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { useWebSocket } from '@/hooks/useWebSocket';
 import { useNodesQuery } from '@/api/queries/useNodesQuery';
+import { useHostsQuery } from '@/api/queries/useHostsQuery';
+import { withMtprotoHostEndpoints } from '@/lib/hosts/host-link';
 import AppSidebar from '@/layouts/AppSidebar';
 const TextModal = lazy(() => import('@/components/feedback/TextModal'));
 import type { TextModalTab } from '@/components/feedback/TextModal';
@@ -112,6 +114,16 @@ export default function InboundsPage() {
   }, [messageApi]);
 
   const { nodes: nodesList, fetched: nodesFetched } = useNodesQuery();
+  // MTProto share links are generated from this list, so an empty one must mean
+  // "no hosts" and not "not loaded yet" — the gate below waits for it.
+  const {
+    hosts,
+    fetched: hostsFetched,
+    fetchError: hostsFetchError,
+    refetch: refetchHosts,
+  } = useHostsQuery();
+  // A background refetch that fails while rows are still cached is not fatal.
+  const hostsError = hosts.length > 0 ? '' : hostsFetchError;
   const nodesById = useMemo(() => {
     const map = new Map<number, ReturnType<typeof useNodesQuery>['nodes'][number]>();
     for (const n of nodesList || []) map.set(n.id, n);
@@ -325,11 +337,19 @@ export default function InboundsPage() {
   const exportInboundLinks = useCallback(
     (dbInbound: DBInbound) => {
       const projected = checkFallback(dbInbound);
+      const hostOverride = hostOverrideFor(dbInbound);
+      const fallbackHostname = preferPublicHost(window.location.hostname, subSettings.publicHost);
       const genInput = {
-        inbound: inboundFromDb(projected),
+        inbound: withMtprotoHostEndpoints(
+          inboundFromDb(projected),
+          dbInbound.id,
+          hosts,
+          hostOverride,
+          fallbackHostname,
+        ),
         remark: projected.remark,
-        hostOverride: hostOverrideFor(dbInbound),
-        fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
+        hostOverride,
+        fallbackHostname,
       };
       const content = genInboundLinks(genInput);
       const tabs: TextModalTab[] | undefined = projected.isWireguard
@@ -358,7 +378,7 @@ export default function InboundsPage() {
         tabs,
       });
     },
-    [checkFallback, hostOverrideFor, subSettings.publicHost, openText, t],
+    [checkFallback, hostOverrideFor, hosts, subSettings.publicHost, openText, t],
   );
 
   const exportInboundClipboard = useCallback(
@@ -708,16 +728,27 @@ export default function InboundsPage() {
 
         <Layout className="content-shell">
           <Layout.Content id="content-layout" className="content-area">
-            <Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
-              {!fetched ? (
+            <Spin
+              spinning={!fetched || !hostsFetched}
+              delay={200}
+              description={t('loading')}
+              size="large"
+            >
+              {!fetched || !hostsFetched ? (
                 <div className="loading-spacer" />
-              ) : fetchError ? (
+              ) : fetchError || hostsError ? (
                 <Result
                   status="error"
                   title={t('somethingWentWrong')}
-                  subTitle={fetchError}
+                  subTitle={fetchError || hostsError}
                   extra={
-                    <Button type="primary" onClick={refresh}>
+                    <Button
+                      type="primary"
+                      onClick={() => {
+                        void refresh();
+                        void refetchHosts();
+                      }}
+                    >
                       {t('refresh')}
                     </Button>
                   }
@@ -809,6 +840,7 @@ export default function InboundsPage() {
             ipLimitEnable={ipLimitEnable}
             tgBotEnable={tgBotEnable}
             subSettings={subSettings}
+            hosts={hosts}
             lastOnlineMap={lastOnlineMap}
             nodeAddress={infoNodeAddress}
           />
@@ -821,6 +853,7 @@ export default function InboundsPage() {
             client={null}
             nodeAddress={qrNodeAddress}
             subSettings={subSettings}
+            hosts={hosts}
           />
         </LazyMount>
         <LazyMount when={attachOpen}>

+ 42 - 31
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -490,8 +490,14 @@ export default function InboundFormModal({
    */
   useEffect(() => {
     if (!open) return;
-    if (!availableNodesFetched || !protocol) return;
+    if (!protocol) return;
     const current = getV('shareAddrStrategy') as InboundFormValues['shareAddrStrategy'] | undefined;
+    if (protocol === Protocols.MTPROTO) {
+      if (current !== 'listen') setV('shareAddrStrategy', 'listen');
+      if (getV('shareAddr')) setV('shareAddr', '');
+      return;
+    }
+    if (!availableNodesFetched) return;
     if (!nodeShareOptionAvailable && (current ?? 'node') === 'node') {
       setV('shareAddrStrategy', 'listen');
     }
@@ -643,37 +649,42 @@ export default function InboundFormModal({
         <Input placeholder={t('pages.inbounds.monitorDesc')} />
       </FormField>
 
-      <FormField
-        name="shareAddrStrategy"
-        label={labelWithHint(
-          t('pages.inbounds.form.shareAddrStrategy'),
-          t('pages.inbounds.form.shareAddrStrategyHelp'),
-        )}
-      >
-        <Select
-          options={SHARE_ADDR_STRATEGIES.filter(
-            (strategy) => strategy !== 'node' || nodeShareOptionAvailable,
-          ).map((strategy) => ({
-            value: strategy,
-            label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`),
-          }))}
-        />
-      </FormField>
-
-      {shareAddrStrategy === 'custom' && (
-        <FormField
-          name="shareAddr"
-          label={labelWithHint(
-            t('pages.inbounds.form.shareAddr'),
-            t('pages.inbounds.form.shareAddrHelp'),
+      {protocol !== Protocols.MTPROTO && (
+        <>
+          <FormField
+            name="shareAddrStrategy"
+            label={labelWithHint(
+              t('pages.inbounds.form.shareAddrStrategy'),
+              t('pages.inbounds.form.shareAddrStrategyHelp'),
+            )}
+          >
+            <Select
+              options={SHARE_ADDR_STRATEGIES.filter(
+                (strategy) => strategy !== 'node' || nodeShareOptionAvailable,
+              ).map((strategy) => ({
+                value: strategy,
+                label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`),
+              }))}
+            />
+          </FormField>
+
+          {shareAddrStrategy === 'custom' && (
+            <FormField
+              name="shareAddr"
+              label={labelWithHint(
+                t('pages.inbounds.form.shareAddr'),
+                t('pages.inbounds.form.shareAddrHelp'),
+              )}
+              rules={{
+                validate: (value) =>
+                  isValidShareAddrInput(String(value ?? '')) ||
+                  t('pages.inbounds.form.shareAddrHelp'),
+              }}
+            >
+              <Input placeholder="edge.example.com" />
+            </FormField>
           )}
-          rules={{
-            validate: (value) =>
-              isValidShareAddrInput(String(value ?? '')) || t('pages.inbounds.form.shareAddrHelp'),
-          }}
-        >
-          <Input placeholder="edge.example.com" />
-        </FormField>
+        </>
       )}
 
       <FormField

+ 14 - 2
frontend/src/pages/inbounds/info/InboundInfoModal.tsx

@@ -17,6 +17,7 @@ import {
   preferPublicHost,
 } from '@/lib/xray/inbound-link';
 import { inboundFromDb } from '@/lib/xray/inbound-from-db';
+import { withMtprotoHostEndpoints } from '@/lib/hosts/host-link';
 
 import {
   buildInboundInfo,
@@ -29,6 +30,8 @@ import {
 import type { ClientSetting, ClientStats, InboundInfo, InboundInfoModalProps } from './types';
 import './InboundInfoModal.css';
 
+const EMPTY_HOSTS: NonNullable<InboundInfoModalProps['hosts']> = [];
+
 export default function InboundInfoModal({
   open,
   onClose,
@@ -40,6 +43,7 @@ export default function InboundInfoModal({
   tgBotEnable = false,
   nodeAddress = '',
   subSettings,
+  hosts = EMPTY_HOSTS,
   lastOnlineMap = {},
 }: InboundInfoModalProps) {
   const { t } = useTranslation();
@@ -110,6 +114,7 @@ export default function InboundInfoModal({
     clientIndex: typeof clientIndex;
     nodeAddress: typeof nodeAddress;
     subSettings: typeof subSettings;
+    hosts: typeof hosts;
     ipLimitEnable: typeof ipLimitEnable;
   } | null>(null);
   if (
@@ -120,9 +125,10 @@ export default function InboundInfoModal({
       syncedProps.clientIndex !== clientIndex ||
       syncedProps.nodeAddress !== nodeAddress ||
       syncedProps.subSettings !== subSettings ||
+      syncedProps.hosts !== hosts ||
       syncedProps.ipLimitEnable !== ipLimitEnable)
   ) {
-    setSyncedProps({ dbInbound, clientIndex, nodeAddress, subSettings, ipLimitEnable });
+    setSyncedProps({ dbInbound, clientIndex, nodeAddress, subSettings, hosts, ipLimitEnable });
     const info = buildInboundInfo(dbInbound);
     setInbound(info);
     setActiveTab(info.clients.length > 0 ? 'client' : 'inbound');
@@ -135,11 +141,17 @@ export default function InboundInfoModal({
       : null;
     setClientStats(stats);
 
-    const inboundForLinks = inboundFromDb(dbInbound);
     const fallbackHostname = preferPublicHost(
       window.location.hostname,
       subSettings?.publicHost ?? '',
     );
+    const inboundForLinks = withMtprotoHostEndpoints(
+      inboundFromDb(dbInbound),
+      dbInbound.id,
+      hosts,
+      nodeAddress,
+      fallbackHostname,
+    );
     if (info.protocol === Protocols.WIREGUARD) {
       setWireguardConfigs(
         genWireguardConfigs({

+ 2 - 0
frontend/src/pages/inbounds/info/types.ts

@@ -1,4 +1,5 @@
 import type { SubSettings } from '../useInbounds';
+import type { HostRecord } from '@/schemas/api/host';
 
 export interface ClientStats {
   email: string;
@@ -82,5 +83,6 @@ export interface InboundInfoModalProps {
   tgBotEnable?: boolean;
   nodeAddress?: string;
   subSettings?: SubSettings;
+  hosts?: HostRecord[];
   lastOnlineMap?: Record<string, number>;
 }

+ 18 - 4
frontend/src/pages/inbounds/qr/QrCodeModal.tsx

@@ -14,6 +14,8 @@ import {
   preferPublicHost,
 } from '@/lib/xray/inbound-link';
 import { inboundFromDb, type DbInboundLike } from '@/lib/xray/inbound-from-db';
+import { withMtprotoHostEndpoints } from '@/lib/hosts/host-link';
+import type { HostRecord } from '@/schemas/api/host';
 import QrPanel from './QrPanel';
 import type { SubSettings } from '../useInbounds';
 
@@ -26,10 +28,11 @@ interface ClientSetting {
 interface QrCodeModalProps {
   open: boolean;
   onClose: () => void;
-  dbInbound: (DbInboundLike & { remark?: string }) | null;
+  dbInbound: (DbInboundLike & { id: number; remark?: string }) | null;
   client?: ClientSetting | null;
   nodeAddress?: string;
   subSettings?: SubSettings;
+  hosts?: HostRecord[];
 }
 
 interface QrItem {
@@ -40,6 +43,8 @@ interface QrItem {
   showQr?: boolean;
 }
 
+const EMPTY_HOSTS: HostRecord[] = [];
+
 export default function QrCodeModal({
   open,
   onClose,
@@ -47,6 +52,7 @@ export default function QrCodeModal({
   client = null,
   nodeAddress = '',
   subSettings,
+  hosts = EMPTY_HOSTS,
 }: QrCodeModalProps) {
   const { t } = useTranslation();
   const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
@@ -65,6 +71,7 @@ export default function QrCodeModal({
     client: typeof client;
     nodeAddress: typeof nodeAddress;
     subSettings: typeof subSettings;
+    hosts: typeof hosts;
   } | null>(null);
   if (
     open &&
@@ -73,14 +80,21 @@ export default function QrCodeModal({
       syncedProps.dbInbound !== dbInbound ||
       syncedProps.client !== client ||
       syncedProps.nodeAddress !== nodeAddress ||
-      syncedProps.subSettings !== subSettings)
+      syncedProps.subSettings !== subSettings ||
+      syncedProps.hosts !== hosts)
   ) {
-    setSyncedProps({ dbInbound, client, nodeAddress, subSettings });
-    const inbound = inboundFromDb(dbInbound);
+    setSyncedProps({ dbInbound, client, nodeAddress, subSettings, hosts });
     const fallbackHostname = preferPublicHost(
       window.location.hostname,
       subSettings?.publicHost ?? '',
     );
+    const inbound = withMtprotoHostEndpoints(
+      inboundFromDb(dbInbound),
+      dbInbound.id,
+      hosts,
+      nodeAddress,
+      fallbackHostname,
+    );
     if (inbound.protocol === Protocols.WIREGUARD) {
       const peerRemark = client?.email
         ? `${dbInbound.remark}-${client.email}`

+ 68 - 1
frontend/src/test/host-link.test.ts

@@ -1,7 +1,8 @@
 /// <reference types="vite/client" />
 import { describe, expect, it } from 'vitest';
 
-import { hostToExternalProxyEntry } from '@/lib/hosts/host-link';
+import { hostToExternalProxyEntry, withMtprotoHostEndpoints } from '@/lib/hosts/host-link';
+import { inboundFromDb } from '@/lib/xray/inbound-from-db';
 
 describe('hostToExternalProxyEntry', () => {
   const base = {
@@ -60,3 +61,69 @@ describe('hostToExternalProxyEntry', () => {
     expect(hostToExternalProxyEntry({ ...base, vlessRoute: '' }).vlessRoute).toBeUndefined();
   });
 });
+
+describe('withMtprotoHostEndpoints', () => {
+  const inbound = inboundFromDb({
+    protocol: 'mtproto',
+    port: 4060,
+    listen: '127.0.0.1',
+    settings: { clients: [] },
+    streamSettings: {},
+    sniffing: {},
+  });
+
+  it('projects enabled raw Hosts onto MTProto share endpoints', () => {
+    const got = withMtprotoHostEndpoints(
+      inbound,
+      7,
+      [
+        {
+          groupId: 'public',
+          inboundIds: [7],
+          hosts: ['proxy.example.com:443', '[2001:db8::1]'],
+          port: 443,
+          remark: 'public',
+        },
+      ],
+      '',
+      'panel.example.com',
+    );
+    expect(got.streamSettings?.externalProxy).toEqual([
+      { forceTls: 'same', dest: 'proxy.example.com', port: 443, remark: 'public' },
+      { forceTls: 'same', dest: '2001:db8::1', port: 4060, remark: 'public' },
+    ]);
+  });
+
+  it('inherits the inbound address for a port-only Host', () => {
+    const got = withMtprotoHostEndpoints(
+      inbound,
+      7,
+      [{ groupId: 'port-only', inboundIds: [7], hosts: [':8443'], port: 8443 }],
+      '',
+      'panel.example.com',
+    );
+    expect(got.streamSettings?.externalProxy).toEqual([
+      { forceTls: 'same', dest: 'panel.example.com', port: 8443, remark: '' },
+    ]);
+  });
+
+  it('ignores disabled, excluded and unrelated Hosts', () => {
+    const got = withMtprotoHostEndpoints(
+      inbound,
+      7,
+      [
+        { groupId: 'disabled', inboundIds: [7], hosts: ['a.example.com:443'], isDisabled: true },
+        {
+          groupId: 'excluded',
+          inboundIds: [7],
+          hosts: ['b.example.com:443'],
+          excludeFromSubTypes: ['raw'],
+        },
+        { groupId: 'other', inboundIds: [8], hosts: ['c.example.com:443'] },
+      ],
+      '',
+      'panel.example.com',
+    );
+    expect(got).toBe(inbound);
+  });
+});

+ 35 - 0
frontend/src/test/inbound-form-modal.test.tsx

@@ -167,6 +167,41 @@ describe('InboundFormModal', () => {
     expect((shareAddrInput as HTMLInputElement).value).toBe('edge.example.test');
   });
 
+  it('uses Hosts instead of showing the custom share address fields for MTProto', async () => {
+    renderWithProviders(
+      <InboundFormModal
+        open
+        mode="edit"
+        dbInbound={
+          new DBInbound({
+            id: 2,
+            port: 4060,
+            listen: '',
+            protocol: 'mtproto',
+            remark: 'proxy',
+            enable: true,
+            settings: { clients: [] },
+            streamSettings: {},
+            sniffing: { enabled: false },
+            nodeId: null,
+            shareAddrStrategy: 'custom',
+            shareAddr: 'proxy.example.test',
+          })
+        }
+        dbInbounds={[]}
+        availableNodes={[]}
+        onClose={() => {}}
+        onSaved={() => {}}
+      />,
+    );
+
+    await act(async () => {
+      await new Promise((resolve) => setTimeout(resolve, 0));
+    });
+    expect(fieldLabels()).not.toContain('Share address strategy');
+    expect(screen.queryByDisplayValue('proxy.example.test')).toBeNull();
+  });
+
   it('keeps the persisted node share strategy through the nodes-loading race (#5375)', async () => {
     const node = { id: 1, name: 'arm2', enable: true, status: 'online' } as never;
     const buildInbound = () =>

+ 55 - 0
internal/database/db.go

@@ -410,6 +410,57 @@ func seedHostsFromExternalProxy() error {
 	})
 }
 
+func seedMtprotoCustomShareAddrToHosts() error {
+	const seederName = "MtprotoCustomShareAddrToHosts"
+	var count int64
+	if err := db.Model(&model.HistoryOfSeeders{}).Where("seeder_name = ?", seederName).Count(&count).Error; err != nil {
+		return err
+	}
+	if count > 0 {
+		return nil
+	}
+
+	return db.Transaction(func(tx *gorm.DB) error {
+		var inbounds []model.Inbound
+		if err := tx.Where("protocol = ? AND TRIM(COALESCE(share_addr_strategy, '')) = ?", string(model.MTProto), "custom").Find(&inbounds).Error; err != nil {
+			return err
+		}
+		for _, inbound := range inbounds {
+			if err := CreateHostFromMtprotoCustomShareAddr(tx, inbound.Id, inbound.ShareAddr); err != nil {
+				return err
+			}
+			if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).Updates(map[string]any{
+				"share_addr_strategy": "listen",
+				"share_addr":          "",
+			}).Error; err != nil {
+				return err
+			}
+		}
+		return tx.Create(&model.HistoryOfSeeders{SeederName: seederName}).Error
+	})
+}
+
+func CreateHostFromMtprotoCustomShareAddr(tx *gorm.DB, inboundId int, rawAddress string) error {
+	address := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(rawAddress), "]"), "[")
+	if address == "" {
+		return nil
+	}
+	var sameAddress []model.Host
+	if err := tx.Where("inbound_id = ? AND address = ? AND is_disabled = ?", inboundId, address, false).
+		Find(&sameAddress).Error; err != nil {
+		return err
+	}
+	for _, host := range sameAddress {
+		if !slices.Contains(host.ExcludeFromSubTypes, "raw") {
+			return nil
+		}
+	}
+	return tx.Create(&model.Host{
+		GroupId: random.NumLower(16), InboundId: inboundId,
+		Remark: address, Address: address, Security: "same",
+	}).Error
+}
+
 func seedWireguardPeersToClients() error {
 	var history []string
 	if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
@@ -1314,6 +1365,10 @@ func runSeeders(isUsersEmpty bool) error {
 		return err
 	}
 
+	if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
+		return err
+	}
+
 	if err := resetIpLimitsWithoutFail2ban(); err != nil {
 		return err
 	}

+ 165 - 0
internal/database/host_migration_test.go

@@ -190,3 +190,168 @@ func TestMigrate_Postgres(t *testing.T) {
 		t.Fatalf("pg host count after 2nd run = %d, want 2 (idempotent)", count)
 	}
 }
+
+func TestMigrateMtprotoCustomShareAddrToHosts(t *testing.T) {
+	initMigrateDB(t)
+	ib := &model.Inbound{
+		UserId: 1, Tag: "mtproto-custom", Enable: true, Port: 4060, Protocol: model.MTProto,
+		Remark: "MTProto", Settings: `{"clients":[]}`, StreamSettings: `{}`,
+		ShareAddrStrategy: "custom", ShareAddr: "proxy.example.com",
+	}
+	if err := GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+
+	if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
+		t.Fatalf("migrate: %v", err)
+	}
+
+	var got model.Inbound
+	if err := GetDB().First(&got, ib.Id).Error; err != nil {
+		t.Fatalf("reload inbound: %v", err)
+	}
+	if got.ShareAddrStrategy != "listen" || got.ShareAddr != "" {
+		t.Fatalf("share fields = (%q, %q), want (listen, empty)", got.ShareAddrStrategy, got.ShareAddr)
+	}
+	var hosts []model.Host
+	if err := GetDB().Where("inbound_id = ?", ib.Id).Find(&hosts).Error; err != nil {
+		t.Fatalf("load hosts: %v", err)
+	}
+	if len(hosts) != 1 || hosts[0].Remark != "proxy.example.com" || hosts[0].Address != "proxy.example.com" || hosts[0].Port != 0 || hosts[0].Security != "same" {
+		t.Fatalf("migrated hosts = %+v", hosts)
+	}
+	if hosts[0].GroupId == "" {
+		t.Fatal("migrated host has an empty group id")
+	}
+
+	if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
+		t.Fatalf("second migrate: %v", err)
+	}
+	var count int64
+	if err := GetDB().Model(&model.Host{}).Where("inbound_id = ?", ib.Id).Count(&count).Error; err != nil {
+		t.Fatalf("count hosts: %v", err)
+	}
+	if count != 1 {
+		t.Fatalf("host count = %d, want 1", count)
+	}
+}
+
+func TestMigrateMtprotoCustomShareAddrWithUnrelatedHost(t *testing.T) {
+	initMigrateDB(t)
+	ib := &model.Inbound{
+		UserId: 1, Tag: "mtproto-host", Enable: true, Port: 4060, Protocol: model.MTProto,
+		Remark: "MTProto", Settings: `{"clients":[]}`, StreamSettings: `{}`,
+		ShareAddrStrategy: "custom", ShareAddr: "old.example.com",
+	}
+	if err := GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	existing := &model.Host{
+		GroupId: "existing", InboundId: ib.Id, Remark: "public",
+		Address: "new.example.com", Port: 443, Security: "same", IsDisabled: true,
+	}
+	if err := GetDB().Create(existing).Error; err != nil {
+		t.Fatalf("create host: %v", err)
+	}
+
+	if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
+		t.Fatalf("migrate: %v", err)
+	}
+
+	var hosts []model.Host
+	if err := GetDB().Where("inbound_id = ?", ib.Id).Order("id asc").Find(&hosts).Error; err != nil {
+		t.Fatalf("load hosts: %v", err)
+	}
+	if len(hosts) != 2 || hosts[0].Id != existing.Id {
+		t.Fatalf("hosts = %+v, want the existing host plus the migrated one", hosts)
+	}
+	if hosts[1].Address != "old.example.com" || hosts[1].Port != 0 || hosts[1].IsDisabled {
+		t.Fatalf("migrated host = %+v, want enabled old.example.com on the inbound port", hosts[1])
+	}
+	var got model.Inbound
+	if err := GetDB().First(&got, ib.Id).Error; err != nil {
+		t.Fatalf("reload inbound: %v", err)
+	}
+	if got.ShareAddrStrategy != "listen" || got.ShareAddr != "" {
+		t.Fatalf("share fields = (%q, %q), want (listen, empty)", got.ShareAddrStrategy, got.ShareAddr)
+	}
+}
+
+func TestMigrateMtprotoCustomShareAddrSkipsHostWithSameAddress(t *testing.T) {
+	initMigrateDB(t)
+	ib := &model.Inbound{
+		UserId: 1, Tag: "mtproto-dup", Enable: true, Port: 4060, Protocol: model.MTProto,
+		Remark: "MTProto", Settings: `{"clients":[]}`, StreamSettings: `{}`,
+		ShareAddrStrategy: "custom", ShareAddr: "proxy.example.com",
+	}
+	if err := GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	existing := &model.Host{
+		GroupId: "existing", InboundId: ib.Id, Remark: "public",
+		Address: "proxy.example.com", Port: 443, Security: "same",
+	}
+	if err := GetDB().Create(existing).Error; err != nil {
+		t.Fatalf("create host: %v", err)
+	}
+
+	if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
+		t.Fatalf("migrate: %v", err)
+	}
+
+	var hosts []model.Host
+	if err := GetDB().Where("inbound_id = ?", ib.Id).Find(&hosts).Error; err != nil {
+		t.Fatalf("load hosts: %v", err)
+	}
+	if len(hosts) != 1 || hosts[0].Id != existing.Id {
+		t.Fatalf("hosts = %+v, want only the existing host %d", hosts, existing.Id)
+	}
+}
+
+func TestMigrateMtprotoCustomShareAddrWithUnusableSameAddressHost(t *testing.T) {
+	cases := []struct {
+		name     string
+		existing model.Host
+	}{
+		{"disabled", model.Host{IsDisabled: true}},
+		{"excludes_raw", model.Host{ExcludeFromSubTypes: []string{"raw"}}},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			initMigrateDB(t)
+			ib := &model.Inbound{
+				UserId: 1, Tag: "mtproto-" + c.name, Enable: true, Port: 4060, Protocol: model.MTProto,
+				Remark: "MTProto", Settings: `{"clients":[]}`, StreamSettings: `{}`,
+				ShareAddrStrategy: "custom", ShareAddr: "proxy.example.com",
+			}
+			if err := GetDB().Create(ib).Error; err != nil {
+				t.Fatalf("create inbound: %v", err)
+			}
+			existing := c.existing
+			existing.GroupId = "existing"
+			existing.InboundId = ib.Id
+			existing.Remark = "parked"
+			existing.Address = "proxy.example.com"
+			existing.Security = "same"
+			if err := GetDB().Create(&existing).Error; err != nil {
+				t.Fatalf("create host: %v", err)
+			}
+
+			if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
+				t.Fatalf("migrate: %v", err)
+			}
+
+			var hosts []model.Host
+			if err := GetDB().Where("inbound_id = ?", ib.Id).Order("id asc").Find(&hosts).Error; err != nil {
+				t.Fatalf("load hosts: %v", err)
+			}
+			if len(hosts) != 2 {
+				t.Fatalf("hosts = %+v, want the parked host plus a usable one", hosts)
+			}
+			migrated := hosts[1]
+			if migrated.Address != "proxy.example.com" || migrated.IsDisabled || len(migrated.ExcludeFromSubTypes) != 0 {
+				t.Fatalf("migrated host = %+v, want an enabled raw-included proxy.example.com", migrated)
+			}
+		})
+	}
+}

+ 5 - 0
internal/sub/links.go

@@ -38,6 +38,11 @@ func (p *LinkProvider) SubLinksForSubId(host, subId string) ([]string, error) {
 func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string {
 	svc := p.build(host)
 	svc.projectThroughFallbackMaster(inbound)
+	if endpoints := svc.hostEndpoints(inbound, "raw"); len(endpoints) > 0 {
+		if client, ok := svc.clientForLink(inbound, email); ok {
+			return splitLinkLines(svc.linkFromHosts(inbound, client, endpoints))
+		}
+	}
 	return splitLinkLines(svc.GetLink(inbound, email))
 }
 

+ 21 - 0
internal/sub/links_test.go

@@ -2,7 +2,10 @@ package sub
 
 import (
 	"reflect"
+	"strings"
 	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
 func TestSplitLinkLines(t *testing.T) {
@@ -38,3 +41,21 @@ func TestSplitLinkLines_WhitespaceOnlyHasNoEntries(t *testing.T) {
 		t.Fatalf("splitLinkLines(whitespace) = %#v, want empty slice", got)
 	}
 }
+
+func TestLinksForClient_UsesHostEndpoints(t *testing.T) {
+	seedSubDB(t)
+	inbound := seedSubInbound(t, "s-gate", "gate", 4431, 1, `{"network":"tcp","security":"none"}`)
+	seedHost(t, &model.Host{
+		InboundId: inbound.Id, Remark: "public", Address: "proxy.example.com",
+		Port: 443, Security: "same",
+	})
+
+	links := NewLinkProvider().LinksForClient("req.example.com", inbound, "gate@e")
+
+	if len(links) != 1 {
+		t.Fatalf("links = %d, want 1: %v", len(links), links)
+	}
+	if !strings.Contains(links[0], "proxy.example.com:443") {
+		t.Fatalf("link = %q, want the host endpoint proxy.example.com:443", links[0])
+	}
+}

+ 23 - 11
internal/sub/service.go

@@ -820,12 +820,8 @@ func (s *SubService) genAmneziaWGLink(inbound *model.Inbound, email string) stri
 	return "vpn://" + base64.RawURLEncoding.EncodeToString([]byte(text))
 }
 
-// genMtprotoLink builds a per-client Telegram proxy deep link for an mtproto
-// inbound: the server/port pair plus the client's own FakeTLS secret. The link
-// carries no remark fragment — Telegram proxy deep links have no name field, and
-// a trailing "#remark" is appended to the last query value by lenient parsers,
-// corrupting the server address. The remark is shown separately in the panel UI.
-// Returns "" when the client has no secret.
+// genMtprotoLink builds one Telegram link per advertised endpoint with the client's FakeTLS secret.
+// It omits remarks because lenient parsers fold a fragment into the last query value.
 func (s *SubService) genMtprotoLink(inbound *model.Inbound, email string) string {
 	if inbound.Protocol != model.MTProto {
 		return ""
@@ -834,12 +830,28 @@ func (s *SubService) genMtprotoLink(inbound *model.Inbound, email string) string
 	if !ok || resolved.Secret == "" {
 		return ""
 	}
-	params := map[string]string{
-		"server": s.resolveInboundAddress(inbound),
-		"port":   fmt.Sprintf("%d", inbound.Port),
-		"secret": resolved.Secret,
+	endpoints := []ShareEndpoint{s.inboundDefaultEndpoint(inbound)}
+	stream := unmarshalStreamSettings(inbound.StreamSettings)
+	if externalProxies, ok := stream["externalProxy"].([]any); ok && len(externalProxies) > 0 {
+		overrides := make([]ShareEndpoint, 0, len(externalProxies))
+		for _, raw := range externalProxies {
+			if ep, ok := raw.(map[string]any); ok {
+				overrides = append(overrides, externalProxyToEndpoint(ep))
+			}
+		}
+		if len(overrides) > 0 {
+			endpoints = overrides
+		}
+	}
+	links := make([]string, 0, len(endpoints))
+	for _, endpoint := range endpoints {
+		links = append(links, buildLinkWithParams("tg://proxy", map[string]string{
+			"server": endpoint.Address,
+			"port":   fmt.Sprintf("%d", endpoint.Port),
+			"secret": resolved.Secret,
+		}, ""))
 	}
-	return buildLinkWithParams("tg://proxy", params, "")
+	return strings.Join(links, "\n")
 }
 
 // Protocol link generators are intentionally ordered as:

+ 65 - 0
internal/sub/service_mtproto_test.go

@@ -65,6 +65,71 @@ func TestGenMtprotoLinkNoSecret(t *testing.T) {
 	}
 }
 
+func TestGetSubsMtprotoUsesHostEndpoint(t *testing.T) {
+	initSubDB(t)
+	db := database.GetDB()
+
+	inbound := &model.Inbound{
+		Listen:   "127.0.0.1",
+		Port:     4060,
+		Protocol: model.MTProto,
+		Enable:   true,
+		Tag:      "mt-public-port",
+		Settings: `{"clients":[{"email":"u@mt","enable":true,"subId":"sub-public-port","secret":"` + mtprotoTestSecret + `"}]}`,
+	}
+	if err := db.Create(inbound).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	if err := db.Create(&model.Host{
+		InboundId: inbound.Id,
+		Remark:    "public",
+		Address:   "proxy.example.com",
+		Port:      443,
+		Security:  "same",
+	}).Error; err != nil {
+		t.Fatalf("create host: %v", err)
+	}
+	client := &model.ClientRecord{Email: "u@mt", SubID: "sub-public-port", Enable: true, Secret: mtprotoTestSecret}
+	if err := db.Create(client).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: inbound.Id}).Error; err != nil {
+		t.Fatalf("attach client: %v", err)
+	}
+
+	links, _, _, _, err := NewSubService("").GetSubs(client.SubID, "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	if len(links) != 1 {
+		t.Fatalf("links = %d, want 1: %v", len(links), links)
+	}
+	u, err := url.Parse(links[0])
+	if err != nil {
+		t.Fatalf("parse link: %v", err)
+	}
+	if got := u.Query().Get("server"); got != "proxy.example.com" {
+		t.Fatalf("server = %q, want proxy.example.com", got)
+	}
+	if got := u.Query().Get("port"); got != "443" {
+		t.Fatalf("port = %q, want public host port 443", got)
+	}
+	clientLinks := NewLinkProvider().LinksForClient("sub.example.com", inbound, client.Email)
+	if len(clientLinks) != 1 {
+		t.Fatalf("client links = %d, want 1: %v", len(clientLinks), clientLinks)
+	}
+	clientURL, err := url.Parse(clientLinks[0])
+	if err != nil {
+		t.Fatalf("parse client link: %v", err)
+	}
+	if got := clientURL.Query().Get("server"); got != "proxy.example.com" {
+		t.Fatalf("client link server = %q, want proxy.example.com", got)
+	}
+	if got := clientURL.Query().Get("port"); got != "443" {
+		t.Fatalf("client link port = %q, want 443", got)
+	}
+}
+
 // Regression: an mtproto inbound must resolve for a subscription id the same way
 // every other client-bearing protocol does. It was previously dropped from the
 // getInboundsBySubId protocol allowlist, so multi-client MTProto subscriptions

+ 29 - 0
internal/web/service/inbound.go

@@ -57,6 +57,11 @@ func normalizeInboundShareAddress(inbound *model.Inbound) {
 	if inbound == nil {
 		return
 	}
+	if inbound.Protocol == model.MTProto {
+		inbound.ShareAddrStrategy = "listen"
+		inbound.ShareAddr = ""
+		return
+	}
 	inbound.ShareAddrStrategy = normalizeInboundShareAddrStrategy(inbound.ShareAddrStrategy)
 	if addr, err := normalizeInboundShareHost(inbound.ShareAddr); err == nil {
 		inbound.ShareAddr = addr
@@ -69,6 +74,11 @@ func normalizeInboundShareAddressStrict(inbound *model.Inbound) error {
 	if inbound == nil {
 		return nil
 	}
+	if inbound.Protocol == model.MTProto {
+		inbound.ShareAddrStrategy = "listen"
+		inbound.ShareAddr = ""
+		return nil
+	}
 	inbound.ShareAddrStrategy = normalizeInboundShareAddrStrategy(inbound.ShareAddrStrategy)
 	addr, err := normalizeInboundShareHost(inbound.ShareAddr)
 	if err != nil {
@@ -113,6 +123,17 @@ func normalizeInboundShareHost(raw string) (string, error) {
 	return host, nil
 }
 
+func legacyMtprotoShareAddr(inbound *model.Inbound) string {
+	if inbound == nil || inbound.Protocol != model.MTProto || strings.TrimSpace(inbound.ShareAddrStrategy) != "custom" {
+		return ""
+	}
+	addr, err := normalizeInboundShareHost(inbound.ShareAddr)
+	if err != nil {
+		return ""
+	}
+	return addr
+}
+
 func normalizeInboundShareAddressColumns(tx *gorm.DB) error {
 	if tx == nil || !tx.Migrator().HasColumn(&model.Inbound{}, "share_addr_strategy") {
 		return nil
@@ -943,6 +964,7 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet
 // Returns the created inbound, whether Xray needs restart, and any error.
 func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
 	inbound.Id = 0
+	legacyShareAddr := legacyMtprotoShareAddr(inbound)
 	inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
 	// Normalize streamSettings based on protocol
 	s.normalizeStreamSettings(inbound)
@@ -1120,6 +1142,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 		if _, err := database.CreateHostsFromExternalProxy(tx, inbound.Id, inbound.StreamSettings); err != nil {
 			return err
 		}
+		if err := database.CreateHostFromMtprotoCustomShareAddr(tx, inbound.Id, legacyShareAddr); err != nil {
+			return err
+		}
 		if inbound.NodeID != nil {
 			nodeID := *inbound.NodeID
 			if err := (&NodeService{}).EnsureInboundTagAllowedTx(tx, nodeID, inbound.Tag); err != nil {
@@ -1463,6 +1488,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
 }
 
 func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
+	legacyShareAddr := legacyMtprotoShareAddr(inbound)
 	inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
 	// Normalize streamSettings based on protocol
 	s.normalizeStreamSettings(inbound)
@@ -1634,6 +1660,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 			}
 			oldInbound.ShareAddrStrategy = inbound.ShareAddrStrategy
 			oldInbound.ShareAddr = inbound.ShareAddr
+			if err := database.CreateHostFromMtprotoCustomShareAddr(tx, inbound.Id, legacyShareAddr); err != nil {
+				return err
+			}
 		}
 		if oldTagWasAuto && inbound.Tag == tag {
 			inbound.Tag = ""

+ 72 - 0
internal/web/service/inbound_import_external_proxy_test.go

@@ -101,3 +101,75 @@ func TestAddInbound_NoExternalProxyCreatesNoHosts(t *testing.T) {
 		t.Fatalf("host count = %d, want 0", count)
 	}
 }
+
+func TestAddInboundImportConvertsMtprotoCustomShareAddrToHost(t *testing.T) {
+	setupConflictDB(t)
+	inbound := &model.Inbound{
+		UserId: 1, Tag: "mt-import", Port: 4060, Protocol: model.MTProto,
+		Settings:       `{"clients":[{"email":"mt-user","enable":true,"secret":"ee0123456789abcdef0123456789abcdef"}]}`,
+		StreamSettings: `{}`, ShareAddrStrategy: "custom", ShareAddr: "proxy.example.com",
+	}
+	created, _, err := (&InboundService{}).AddInbound(inbound)
+	if err != nil {
+		t.Fatalf("AddInbound: %v", err)
+	}
+	if created.ShareAddrStrategy != "listen" || created.ShareAddr != "" {
+		t.Fatalf("share fields = (%q, %q), want (listen, empty)", created.ShareAddrStrategy, created.ShareAddr)
+	}
+	var hosts []model.Host
+	if err := database.GetDB().Where("inbound_id = ?", created.Id).Find(&hosts).Error; err != nil {
+		t.Fatalf("load hosts: %v", err)
+	}
+	if len(hosts) != 1 || hosts[0].Address != "proxy.example.com" || hosts[0].Port != 0 {
+		t.Fatalf("hosts = %+v, want one inherited-port proxy.example.com host", hosts)
+	}
+}
+
+func TestAddInboundImportDropsInvalidMtprotoCustomShareAddr(t *testing.T) {
+	setupConflictDB(t)
+	inbound := &model.Inbound{
+		UserId: 1, Tag: "mt-bad-import", Port: 4061, Protocol: model.MTProto,
+		Settings:       `{"clients":[{"email":"mt-bad","enable":true,"secret":"ee0123456789abcdef0123456789abcdef"}]}`,
+		StreamSettings: `{}`, ShareAddrStrategy: "custom", ShareAddr: "https://proxy.example.com/path",
+	}
+	created, _, err := (&InboundService{}).AddInbound(inbound)
+	if err != nil {
+		t.Fatalf("AddInbound: %v", err)
+	}
+	var count int64
+	if err := database.GetDB().Model(&model.Host{}).Where("inbound_id = ?", created.Id).Count(&count).Error; err != nil {
+		t.Fatalf("count hosts: %v", err)
+	}
+	if count != 0 {
+		t.Fatalf("host count = %d, want 0", count)
+	}
+}
+
+func TestUpdateInboundConvertsMtprotoCustomShareAddrToHost(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "mt-update", "127.0.0.1", 4062, model.MTProto, `{}`,
+		`{"clients":[{"email":"mt-upd","enable":true,"secret":"ee0123456789abcdef0123456789abcdef"}]}`)
+
+	var existing model.Inbound
+	if err := database.GetDB().Where("tag = ?", "mt-update").First(&existing).Error; err != nil {
+		t.Fatalf("read seeded row: %v", err)
+	}
+	update := existing
+	update.ShareAddrStrategy = "custom"
+	update.ShareAddr = "edge.example.com"
+	updated, _, err := (&InboundService{}).UpdateInbound(&update)
+	if err != nil {
+		t.Fatalf("UpdateInbound: %v", err)
+	}
+	if updated.ShareAddrStrategy != "listen" || updated.ShareAddr != "" {
+		t.Fatalf("share fields = (%q, %q), want (listen, empty)", updated.ShareAddrStrategy, updated.ShareAddr)
+	}
+
+	var hosts []model.Host
+	if err := database.GetDB().Where("inbound_id = ?", existing.Id).Find(&hosts).Error; err != nil {
+		t.Fatalf("load hosts: %v", err)
+	}
+	if len(hosts) != 1 || hosts[0].Address != "edge.example.com" || hosts[0].Port != 0 {
+		t.Fatalf("hosts = %+v, want one inherited-port edge.example.com host", hosts)
+	}
+}

+ 12 - 0
internal/web/service/inbound_update_tag_test.go

@@ -204,3 +204,15 @@ func TestNormalizeInboundShareAddressStrict_RequiresHostOnly(t *testing.T) {
 		})
 	}
 }
+
+func TestNormalizeInboundShareAddressStrictDropsMtprotoCustomAddress(t *testing.T) {
+	inbound := &model.Inbound{
+		Protocol: model.MTProto, ShareAddrStrategy: "custom", ShareAddr: "proxy.example.com",
+	}
+	if err := normalizeInboundShareAddressStrict(inbound); err != nil {
+		t.Fatalf("normalizeInboundShareAddressStrict: %v", err)
+	}
+	if inbound.ShareAddrStrategy != "listen" || inbound.ShareAddr != "" {
+		t.Fatalf("share fields = (%q, %q), want (listen, empty)", inbound.ShareAddrStrategy, inbound.ShareAddr)
+	}
+}