From 5a63d5d468ac50e16e5b1dc8db87dc0cf6934e29 Mon Sep 17 00:00:00 2001 From: Amirmohammad Sadat Shokouhi Date: Tue, 8 Sep 2026 17:06:57 +0330 Subject: [PATCH] fix(mtproto): use hosts for public share links (#6369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../content/docs/en/reference/api/clients.mdx | 28 +-- docs/public/openapi.json | 2 +- frontend/public/openapi.json | 2 +- frontend/src/lib/hosts/host-link.ts | 51 +++++- frontend/src/pages/api-docs/endpoints.ts | 2 +- frontend/src/pages/inbounds/InboundsPage.tsx | 51 +++++- .../pages/inbounds/form/InboundFormModal.tsx | 71 ++++---- .../pages/inbounds/info/InboundInfoModal.tsx | 16 +- frontend/src/pages/inbounds/info/types.ts | 2 + .../src/pages/inbounds/qr/QrCodeModal.tsx | 22 ++- frontend/src/test/host-link.test.ts | 69 +++++++- frontend/src/test/inbound-form-modal.test.tsx | 35 ++++ internal/database/db.go | 55 ++++++ internal/database/host_migration_test.go | 165 ++++++++++++++++++ internal/sub/links.go | 5 + internal/sub/links_test.go | 21 +++ internal/sub/service.go | 34 ++-- internal/sub/service_mtproto_test.go | 65 +++++++ internal/web/service/inbound.go | 29 +++ .../inbound_import_external_proxy_test.go | 72 ++++++++ .../web/service/inbound_update_tag_test.go | 12 ++ 21 files changed, 734 insertions(+), 75 deletions(-) diff --git a/docs/content/docs/en/reference/api/clients.mdx b/docs/content/docs/en/reference/api/clients.mdx index b4696ccf4..f30813776 100644 --- a/docs/content/docs/en/reference/api/clients.mdx +++ b/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 diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 59d8dca40..5dadf43fd 100644 --- a/docs/public/openapi.json +++ b/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": [ { diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 59d8dca40..5dadf43fd 100644 --- a/frontend/public/openapi.json +++ b/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": [ { diff --git a/frontend/src/lib/hosts/host-link.ts b/frontend/src/lib/hosts/host-link.ts index a9a5c237d..8b8f520c5 100644 --- a/frontend/src/lib/hosts/host-link.ts +++ b/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; +} diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 0d10968c6..9962610f5 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/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).' }, ], diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index 8f12aba93..3a2b557b8 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/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['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() { - - {!fetched ? ( + + {!fetched || !hostsFetched ? (
- ) : fetchError ? ( + ) : fetchError || hostsError ? ( + } @@ -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} /> diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index 65664e3f2..6853b4b4e 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/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({ - - strategy !== 'node' || nodeShareOptionAvailable, + ).map((strategy) => ({ + value: strategy, + label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`), + }))} + /> + - {shareAddrStrategy === 'custom' && ( - + isValidShareAddrInput(String(value ?? '')) || + t('pages.inbounds.form.shareAddrHelp'), + }} + > + + )} - rules={{ - validate: (value) => - isValidShareAddrInput(String(value ?? '')) || t('pages.inbounds.form.shareAddrHelp'), - }} - > - - + )} = []; + 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({ diff --git a/frontend/src/pages/inbounds/info/types.ts b/frontend/src/pages/inbounds/info/types.ts index a445ce900..77a7c907d 100644 --- a/frontend/src/pages/inbounds/info/types.ts +++ b/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; } diff --git a/frontend/src/pages/inbounds/qr/QrCodeModal.tsx b/frontend/src/pages/inbounds/qr/QrCodeModal.tsx index 384a98857..05b37b045 100644 --- a/frontend/src/pages/inbounds/qr/QrCodeModal.tsx +++ b/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}` diff --git a/frontend/src/test/host-link.test.ts b/frontend/src/test/host-link.test.ts index fdc4361b2..b7dd45a2f 100644 --- a/frontend/src/test/host-link.test.ts +++ b/frontend/src/test/host-link.test.ts @@ -1,7 +1,8 @@ /// 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); + }); +}); diff --git a/frontend/src/test/inbound-form-modal.test.tsx b/frontend/src/test/inbound-form-modal.test.tsx index 4c5a6b558..3618ee470 100644 --- a/frontend/src/test/inbound-form-modal.test.tsx +++ b/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( + {}} + 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 = () => diff --git a/internal/database/db.go b/internal/database/db.go index 00174bb3f..03af8dfca 100644 --- a/internal/database/db.go +++ b/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 } diff --git a/internal/database/host_migration_test.go b/internal/database/host_migration_test.go index dc8dcd10d..786fd54f7 100644 --- a/internal/database/host_migration_test.go +++ b/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) + } + }) + } +} diff --git a/internal/sub/links.go b/internal/sub/links.go index e281df7b6..1b6f9967b 100644 --- a/internal/sub/links.go +++ b/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)) } diff --git a/internal/sub/links_test.go b/internal/sub/links_test.go index c600199cf..6ba7c6a2b 100644 --- a/internal/sub/links_test.go +++ b/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]) + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index b77baab31..24dfebb90 100644 --- a/internal/sub/service.go +++ b/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 + } } - return buildLinkWithParams("tg://proxy", params, "") + 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 strings.Join(links, "\n") } // Protocol link generators are intentionally ordered as: diff --git a/internal/sub/service_mtproto_test.go b/internal/sub/service_mtproto_test.go index 2e17db4fb..831e9fd7f 100644 --- a/internal/sub/service_mtproto_test.go +++ b/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 diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 7bd62b125..8e199cfc2 100644 --- a/internal/web/service/inbound.go +++ b/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 = "" diff --git a/internal/web/service/inbound_import_external_proxy_test.go b/internal/web/service/inbound_import_external_proxy_test.go index 96553b59b..2153e9486 100644 --- a/internal/web/service/inbound_import_external_proxy_test.go +++ b/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) + } +} diff --git a/internal/web/service/inbound_update_tag_test.go b/internal/web/service/inbound_update_tag_test.go index d55835438..dc7ac676c 100644 --- a/internal/web/service/inbound_update_tag_test.go +++ b/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) + } +}