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