mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 23:27:14 +00:00
fix(mtproto): use hosts for public share links (#6369)
* fix(mtproto): use hosts for public share links Generate MTProto subscription, client, copy, QR, and export links from managed Hosts so reverse-proxied public ports are advertised correctly. Migrate the redundant legacy custom share address into a Host and keep old imports compatible. Closes #5126. * fix(mtproto): keep host share links lossless and consistent Address review on the MTProto hosts share-link change. The migration no longer drops a legacy custom share address: an unrelated (or disabled) Host stopped suppressing it, so only a Host already advertising the same address does. An imported address now clears the same validation the strict normalizer applies to every other protocol before it becomes a Host. Panel and subscription agree on the endpoint a Host advertises: a portless host string inherits the inbound port rather than the group's, and a port-only host inherits the inbound address instead of emitting server=%3A8443. LinksForClient prefers host endpoints for every protocol, the way getSubs and inboundLinks already do, so the client-links API no longer ignores managed hosts. * fix(mtproto): migrate legacy share address past unusable hosts The seeder skipped the conversion whenever any Host already carried the address, including one that is disabled or excludes the raw sub type. hostEndpoints drops those, so nothing advertised the address afterwards and the marker committed with no way back. The duplicate check now mirrors that same predicate. UpdateInbound cleared a legacy MTProto shareAddr without the Host conversion AddInbound runs, so re-applying an inbound definition through the API dropped the public address silently. Both paths share one capture helper now. Refresh the generated clients API reference for the summary reworded in the previous commit. * fix(inbounds): wait for the hosts list before building mtproto links The page destructured only `hosts` from useHostsQuery, and that list reads empty both while /panel/api/hosts/list is in flight and after it fails. withMtprotoHostEndpoints then returns the inbound untouched, so Copy, QR and Export advertise the internal listen port — the endpoint this branch exists to replace. It is worse than not fixing it: the seeder has already moved a legacy custom share address into a Host, so the fallback is the panel's own hostname instead of the operator's address, and the Go generators reading the same rows from the DB stay correct, so the two disagree for one inbound. Fold the query into the page's existing readiness gate, the same way useInbounds and HostsPage already consume that hook, so an empty list means "no hosts" rather than "not loaded yet". The error branch fires only when nothing is cached, so a refetch failing on window focus does not blank a page whose host rows are still perfectly usable. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
committed by
GitHub
parent
d2ac3b4d7a
commit
5a63d5d468
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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).' },
|
||||
],
|
||||
|
||||
@@ -38,6 +38,8 @@ import { useTheme } from '@/hooks/useTheme';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { useWebSocket } from '@/hooks/useWebSocket';
|
||||
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||
import { useHostsQuery } from '@/api/queries/useHostsQuery';
|
||||
import { withMtprotoHostEndpoints } from '@/lib/hosts/host-link';
|
||||
import AppSidebar from '@/layouts/AppSidebar';
|
||||
const TextModal = lazy(() => import('@/components/feedback/TextModal'));
|
||||
import type { TextModalTab } from '@/components/feedback/TextModal';
|
||||
@@ -112,6 +114,16 @@ export default function InboundsPage() {
|
||||
}, [messageApi]);
|
||||
|
||||
const { nodes: nodesList, fetched: nodesFetched } = useNodesQuery();
|
||||
// MTProto share links are generated from this list, so an empty one must mean
|
||||
// "no hosts" and not "not loaded yet" — the gate below waits for it.
|
||||
const {
|
||||
hosts,
|
||||
fetched: hostsFetched,
|
||||
fetchError: hostsFetchError,
|
||||
refetch: refetchHosts,
|
||||
} = useHostsQuery();
|
||||
// A background refetch that fails while rows are still cached is not fatal.
|
||||
const hostsError = hosts.length > 0 ? '' : hostsFetchError;
|
||||
const nodesById = useMemo(() => {
|
||||
const map = new Map<number, ReturnType<typeof useNodesQuery>['nodes'][number]>();
|
||||
for (const n of nodesList || []) map.set(n.id, n);
|
||||
@@ -325,11 +337,19 @@ export default function InboundsPage() {
|
||||
const exportInboundLinks = useCallback(
|
||||
(dbInbound: DBInbound) => {
|
||||
const projected = checkFallback(dbInbound);
|
||||
const hostOverride = hostOverrideFor(dbInbound);
|
||||
const fallbackHostname = preferPublicHost(window.location.hostname, subSettings.publicHost);
|
||||
const genInput = {
|
||||
inbound: inboundFromDb(projected),
|
||||
inbound: withMtprotoHostEndpoints(
|
||||
inboundFromDb(projected),
|
||||
dbInbound.id,
|
||||
hosts,
|
||||
hostOverride,
|
||||
fallbackHostname,
|
||||
),
|
||||
remark: projected.remark,
|
||||
hostOverride: hostOverrideFor(dbInbound),
|
||||
fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
|
||||
hostOverride,
|
||||
fallbackHostname,
|
||||
};
|
||||
const content = genInboundLinks(genInput);
|
||||
const tabs: TextModalTab[] | undefined = projected.isWireguard
|
||||
@@ -358,7 +378,7 @@ export default function InboundsPage() {
|
||||
tabs,
|
||||
});
|
||||
},
|
||||
[checkFallback, hostOverrideFor, subSettings.publicHost, openText, t],
|
||||
[checkFallback, hostOverrideFor, hosts, subSettings.publicHost, openText, t],
|
||||
);
|
||||
|
||||
const exportInboundClipboard = useCallback(
|
||||
@@ -708,16 +728,27 @@ export default function InboundsPage() {
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
|
||||
{!fetched ? (
|
||||
<Spin
|
||||
spinning={!fetched || !hostsFetched}
|
||||
delay={200}
|
||||
description={t('loading')}
|
||||
size="large"
|
||||
>
|
||||
{!fetched || !hostsFetched ? (
|
||||
<div className="loading-spacer" />
|
||||
) : fetchError ? (
|
||||
) : fetchError || hostsError ? (
|
||||
<Result
|
||||
status="error"
|
||||
title={t('somethingWentWrong')}
|
||||
subTitle={fetchError}
|
||||
subTitle={fetchError || hostsError}
|
||||
extra={
|
||||
<Button type="primary" onClick={refresh}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
void refresh();
|
||||
void refetchHosts();
|
||||
}}
|
||||
>
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
}
|
||||
@@ -809,6 +840,7 @@ export default function InboundsPage() {
|
||||
ipLimitEnable={ipLimitEnable}
|
||||
tgBotEnable={tgBotEnable}
|
||||
subSettings={subSettings}
|
||||
hosts={hosts}
|
||||
lastOnlineMap={lastOnlineMap}
|
||||
nodeAddress={infoNodeAddress}
|
||||
/>
|
||||
@@ -821,6 +853,7 @@ export default function InboundsPage() {
|
||||
client={null}
|
||||
nodeAddress={qrNodeAddress}
|
||||
subSettings={subSettings}
|
||||
hosts={hosts}
|
||||
/>
|
||||
</LazyMount>
|
||||
<LazyMount when={attachOpen}>
|
||||
|
||||
@@ -490,8 +490,14 @@ export default function InboundFormModal({
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!availableNodesFetched || !protocol) return;
|
||||
if (!protocol) return;
|
||||
const current = getV('shareAddrStrategy') as InboundFormValues['shareAddrStrategy'] | undefined;
|
||||
if (protocol === Protocols.MTPROTO) {
|
||||
if (current !== 'listen') setV('shareAddrStrategy', 'listen');
|
||||
if (getV('shareAddr')) setV('shareAddr', '');
|
||||
return;
|
||||
}
|
||||
if (!availableNodesFetched) return;
|
||||
if (!nodeShareOptionAvailable && (current ?? 'node') === 'node') {
|
||||
setV('shareAddrStrategy', 'listen');
|
||||
}
|
||||
@@ -643,37 +649,42 @@ export default function InboundFormModal({
|
||||
<Input placeholder={t('pages.inbounds.monitorDesc')} />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
name="shareAddrStrategy"
|
||||
label={labelWithHint(
|
||||
t('pages.inbounds.form.shareAddrStrategy'),
|
||||
t('pages.inbounds.form.shareAddrStrategyHelp'),
|
||||
)}
|
||||
>
|
||||
<Select
|
||||
options={SHARE_ADDR_STRATEGIES.filter(
|
||||
(strategy) => strategy !== 'node' || nodeShareOptionAvailable,
|
||||
).map((strategy) => ({
|
||||
value: strategy,
|
||||
label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`),
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
{protocol !== Protocols.MTPROTO && (
|
||||
<>
|
||||
<FormField
|
||||
name="shareAddrStrategy"
|
||||
label={labelWithHint(
|
||||
t('pages.inbounds.form.shareAddrStrategy'),
|
||||
t('pages.inbounds.form.shareAddrStrategyHelp'),
|
||||
)}
|
||||
>
|
||||
<Select
|
||||
options={SHARE_ADDR_STRATEGIES.filter(
|
||||
(strategy) => strategy !== 'node' || nodeShareOptionAvailable,
|
||||
).map((strategy) => ({
|
||||
value: strategy,
|
||||
label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`),
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{shareAddrStrategy === 'custom' && (
|
||||
<FormField
|
||||
name="shareAddr"
|
||||
label={labelWithHint(
|
||||
t('pages.inbounds.form.shareAddr'),
|
||||
t('pages.inbounds.form.shareAddrHelp'),
|
||||
{shareAddrStrategy === 'custom' && (
|
||||
<FormField
|
||||
name="shareAddr"
|
||||
label={labelWithHint(
|
||||
t('pages.inbounds.form.shareAddr'),
|
||||
t('pages.inbounds.form.shareAddrHelp'),
|
||||
)}
|
||||
rules={{
|
||||
validate: (value) =>
|
||||
isValidShareAddrInput(String(value ?? '')) ||
|
||||
t('pages.inbounds.form.shareAddrHelp'),
|
||||
}}
|
||||
>
|
||||
<Input placeholder="edge.example.com" />
|
||||
</FormField>
|
||||
)}
|
||||
rules={{
|
||||
validate: (value) =>
|
||||
isValidShareAddrInput(String(value ?? '')) || t('pages.inbounds.form.shareAddrHelp'),
|
||||
}}
|
||||
>
|
||||
<Input placeholder="edge.example.com" />
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
preferPublicHost,
|
||||
} from '@/lib/xray/inbound-link';
|
||||
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
|
||||
import { withMtprotoHostEndpoints } from '@/lib/hosts/host-link';
|
||||
|
||||
import {
|
||||
buildInboundInfo,
|
||||
@@ -29,6 +30,8 @@ import {
|
||||
import type { ClientSetting, ClientStats, InboundInfo, InboundInfoModalProps } from './types';
|
||||
import './InboundInfoModal.css';
|
||||
|
||||
const EMPTY_HOSTS: NonNullable<InboundInfoModalProps['hosts']> = [];
|
||||
|
||||
export default function InboundInfoModal({
|
||||
open,
|
||||
onClose,
|
||||
@@ -40,6 +43,7 @@ export default function InboundInfoModal({
|
||||
tgBotEnable = false,
|
||||
nodeAddress = '',
|
||||
subSettings,
|
||||
hosts = EMPTY_HOSTS,
|
||||
lastOnlineMap = {},
|
||||
}: InboundInfoModalProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -110,6 +114,7 @@ export default function InboundInfoModal({
|
||||
clientIndex: typeof clientIndex;
|
||||
nodeAddress: typeof nodeAddress;
|
||||
subSettings: typeof subSettings;
|
||||
hosts: typeof hosts;
|
||||
ipLimitEnable: typeof ipLimitEnable;
|
||||
} | null>(null);
|
||||
if (
|
||||
@@ -120,9 +125,10 @@ export default function InboundInfoModal({
|
||||
syncedProps.clientIndex !== clientIndex ||
|
||||
syncedProps.nodeAddress !== nodeAddress ||
|
||||
syncedProps.subSettings !== subSettings ||
|
||||
syncedProps.hosts !== hosts ||
|
||||
syncedProps.ipLimitEnable !== ipLimitEnable)
|
||||
) {
|
||||
setSyncedProps({ dbInbound, clientIndex, nodeAddress, subSettings, ipLimitEnable });
|
||||
setSyncedProps({ dbInbound, clientIndex, nodeAddress, subSettings, hosts, ipLimitEnable });
|
||||
const info = buildInboundInfo(dbInbound);
|
||||
setInbound(info);
|
||||
setActiveTab(info.clients.length > 0 ? 'client' : 'inbound');
|
||||
@@ -135,11 +141,17 @@ export default function InboundInfoModal({
|
||||
: null;
|
||||
setClientStats(stats);
|
||||
|
||||
const inboundForLinks = inboundFromDb(dbInbound);
|
||||
const fallbackHostname = preferPublicHost(
|
||||
window.location.hostname,
|
||||
subSettings?.publicHost ?? '',
|
||||
);
|
||||
const inboundForLinks = withMtprotoHostEndpoints(
|
||||
inboundFromDb(dbInbound),
|
||||
dbInbound.id,
|
||||
hosts,
|
||||
nodeAddress,
|
||||
fallbackHostname,
|
||||
);
|
||||
if (info.protocol === Protocols.WIREGUARD) {
|
||||
setWireguardConfigs(
|
||||
genWireguardConfigs({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SubSettings } from '../useInbounds';
|
||||
import type { HostRecord } from '@/schemas/api/host';
|
||||
|
||||
export interface ClientStats {
|
||||
email: string;
|
||||
@@ -82,5 +83,6 @@ export interface InboundInfoModalProps {
|
||||
tgBotEnable?: boolean;
|
||||
nodeAddress?: string;
|
||||
subSettings?: SubSettings;
|
||||
hosts?: HostRecord[];
|
||||
lastOnlineMap?: Record<string, number>;
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { hostToExternalProxyEntry } from '@/lib/hosts/host-link';
|
||||
import { hostToExternalProxyEntry, withMtprotoHostEndpoints } from '@/lib/hosts/host-link';
|
||||
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
|
||||
|
||||
describe('hostToExternalProxyEntry', () => {
|
||||
const base = {
|
||||
@@ -60,3 +61,69 @@ describe('hostToExternalProxyEntry', () => {
|
||||
expect(hostToExternalProxyEntry({ ...base, vlessRoute: '' }).vlessRoute).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('withMtprotoHostEndpoints', () => {
|
||||
const inbound = inboundFromDb({
|
||||
protocol: 'mtproto',
|
||||
port: 4060,
|
||||
listen: '127.0.0.1',
|
||||
settings: { clients: [] },
|
||||
streamSettings: {},
|
||||
sniffing: {},
|
||||
});
|
||||
|
||||
it('projects enabled raw Hosts onto MTProto share endpoints', () => {
|
||||
const got = withMtprotoHostEndpoints(
|
||||
inbound,
|
||||
7,
|
||||
[
|
||||
{
|
||||
groupId: 'public',
|
||||
inboundIds: [7],
|
||||
hosts: ['proxy.example.com:443', '[2001:db8::1]'],
|
||||
port: 443,
|
||||
remark: 'public',
|
||||
},
|
||||
],
|
||||
'',
|
||||
'panel.example.com',
|
||||
);
|
||||
expect(got.streamSettings?.externalProxy).toEqual([
|
||||
{ forceTls: 'same', dest: 'proxy.example.com', port: 443, remark: 'public' },
|
||||
{ forceTls: 'same', dest: '2001:db8::1', port: 4060, remark: 'public' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('inherits the inbound address for a port-only Host', () => {
|
||||
const got = withMtprotoHostEndpoints(
|
||||
inbound,
|
||||
7,
|
||||
[{ groupId: 'port-only', inboundIds: [7], hosts: [':8443'], port: 8443 }],
|
||||
'',
|
||||
'panel.example.com',
|
||||
);
|
||||
expect(got.streamSettings?.externalProxy).toEqual([
|
||||
{ forceTls: 'same', dest: 'panel.example.com', port: 8443, remark: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores disabled, excluded and unrelated Hosts', () => {
|
||||
const got = withMtprotoHostEndpoints(
|
||||
inbound,
|
||||
7,
|
||||
[
|
||||
{ groupId: 'disabled', inboundIds: [7], hosts: ['a.example.com:443'], isDisabled: true },
|
||||
{
|
||||
groupId: 'excluded',
|
||||
inboundIds: [7],
|
||||
hosts: ['b.example.com:443'],
|
||||
excludeFromSubTypes: ['raw'],
|
||||
},
|
||||
{ groupId: 'other', inboundIds: [8], hosts: ['c.example.com:443'] },
|
||||
],
|
||||
'',
|
||||
'panel.example.com',
|
||||
);
|
||||
expect(got).toBe(inbound);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,6 +167,41 @@ describe('InboundFormModal', () => {
|
||||
expect((shareAddrInput as HTMLInputElement).value).toBe('edge.example.test');
|
||||
});
|
||||
|
||||
it('uses Hosts instead of showing the custom share address fields for MTProto', async () => {
|
||||
renderWithProviders(
|
||||
<InboundFormModal
|
||||
open
|
||||
mode="edit"
|
||||
dbInbound={
|
||||
new DBInbound({
|
||||
id: 2,
|
||||
port: 4060,
|
||||
listen: '',
|
||||
protocol: 'mtproto',
|
||||
remark: 'proxy',
|
||||
enable: true,
|
||||
settings: { clients: [] },
|
||||
streamSettings: {},
|
||||
sniffing: { enabled: false },
|
||||
nodeId: null,
|
||||
shareAddrStrategy: 'custom',
|
||||
shareAddr: 'proxy.example.test',
|
||||
})
|
||||
}
|
||||
dbInbounds={[]}
|
||||
availableNodes={[]}
|
||||
onClose={() => {}}
|
||||
onSaved={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(fieldLabels()).not.toContain('Share address strategy');
|
||||
expect(screen.queryByDisplayValue('proxy.example.test')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the persisted node share strategy through the nodes-loading race (#5375)', async () => {
|
||||
const node = { id: 1, name: 'arm2', enable: true, status: 'online' } as never;
|
||||
const buildInbound = () =>
|
||||
|
||||
Reference in New Issue
Block a user