import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Divider, Modal, Space, Tabs, Tag, Tooltip } from 'antd'; import { getMessage } from '@/utils/messageBus'; import { CopyOutlined, SyncOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons'; import { HttpUtil, IntlUtil, SizeFormatter, ColorUtils, ClipboardManager, FileManager, } from '@/utils'; import { Protocols } from '@/schemas/primitives'; import InfinityIcon from '@/components/InfinityIcon'; import { useDatepicker } from '@/hooks/useDatepicker'; import { coerceInboundJsonField } from '@/models/dbinbound'; import { canEnableTlsFlow, isSS2022 as isSS2022Helper, isSSMultiUser as isSSMultiUserHelper, } from '@/lib/xray/protocol-capabilities'; import { genAllLinks, genWireguardConfigs, genWireguardLinks, } from '@/lib/xray/inbound-link'; import { inboundFromDb } from '@/lib/xray/inbound-from-db'; import type { SubSettings } from './useInbounds'; import './InboundInfoModal.css'; const LINK_PROTOCOLS: ReadonlySet = new Set([ Protocols.VMESS, Protocols.VLESS, Protocols.TROJAN, Protocols.SHADOWSOCKS, Protocols.HYSTERIA, ]); function hasShareLink(protocol: string): boolean { return LINK_PROTOCOLS.has(protocol); } function readHeader(headers: unknown, name: string): string { const needle = name.toLowerCase(); if (Array.isArray(headers)) { for (const h of headers) { if (h && typeof h === 'object' && String((h as { name?: string }).name ?? '').toLowerCase() === needle) { return String((h as { value?: unknown }).value ?? ''); } } return ''; } if (headers && typeof headers === 'object') { for (const [k, v] of Object.entries(headers as Record)) { if (k.toLowerCase() === needle) { return Array.isArray(v) ? String(v[0] ?? '') : String(v ?? ''); } } } return ''; } function readNetworkHost(stream: Record, network: string): string | null { switch (network) { case 'tcp': { const tcp = stream.tcpSettings as { header?: { request?: { headers?: unknown } } } | undefined; return readHeader(tcp?.header?.request?.headers, 'host'); } case 'ws': { const ws = stream.wsSettings as { host?: string; headers?: unknown } | undefined; return (ws?.host && ws.host.length > 0) ? ws.host : readHeader(ws?.headers, 'host'); } case 'httpupgrade': { const hu = stream.httpupgradeSettings as { host?: string; headers?: unknown } | undefined; return (hu?.host && hu.host.length > 0) ? hu.host : readHeader(hu?.headers, 'host'); } case 'xhttp': { const xh = stream.xhttpSettings as { host?: string; headers?: unknown } | undefined; return (xh?.host && xh.host.length > 0) ? xh.host : readHeader(xh?.headers, 'host'); } default: return null; } } function readNetworkPath(stream: Record, network: string): string | null { switch (network) { case 'tcp': { const tcp = stream.tcpSettings as { header?: { request?: { path?: string[] } } } | undefined; return tcp?.header?.request?.path?.[0] ?? null; } case 'ws': return (stream.wsSettings as { path?: string } | undefined)?.path ?? null; case 'httpupgrade': return (stream.httpupgradeSettings as { path?: string } | undefined)?.path ?? null; case 'xhttp': return (stream.xhttpSettings as { path?: string } | undefined)?.path ?? null; default: return null; } } interface ClientStats { email: string; up: number; down: number; total: number; expiryTime: number; enable?: boolean; } interface ClientSetting { email?: string; id?: string; security?: string; password?: string; flow?: string; subId?: string; totalGB?: number; expiryTime?: number; comment?: string; tgId?: string; enable?: boolean; limitIp?: number; created_at?: number; updated_at?: number; } interface InboundInfo { protocol: string; clients: ClientSetting[]; settings: Record; isTcp: boolean; isWs: boolean; isHttpupgrade: boolean; isXHTTP: boolean; isGrpc: boolean; isSSMultiUser: boolean; isSS2022: boolean; isVlessTlsFlow: boolean; host: string | null; path: string | null; serviceName: string; serverName: string; stream: { network: string; security: string; xhttp?: { mode?: string }; grpc?: { multiMode?: boolean }; }; } interface DBInboundLike { id: number; address: string; port: number; listen: string; protocol: string; remark: string; enable?: boolean; isVMess?: boolean; isVLess?: boolean; isTrojan?: boolean; isSS?: boolean; isMixed?: boolean; isHTTP?: boolean; isWireguard?: boolean; settings: unknown; streamSettings: unknown; sniffing: unknown; clientStats?: ClientStats[]; } function buildInboundInfo(dbInbound: DBInboundLike): InboundInfo { const settings = coerceInboundJsonField(dbInbound.settings) as Record; const stream = coerceInboundJsonField(dbInbound.streamSettings) as Record; const network = (stream.network as string | undefined) ?? ''; const security = (stream.security as string | undefined) ?? 'none'; const clients = Array.isArray(settings.clients) ? (settings.clients as ClientSetting[]) : []; const xhttpSettings = stream.xhttpSettings as { mode?: string } | undefined; const grpcSettings = stream.grpcSettings as { multiMode?: boolean; serviceName?: string } | undefined; let serverName = ''; if (security === 'tls') { const tls = stream.tlsSettings as { sni?: string; serverName?: string } | undefined; serverName = tls?.sni ?? tls?.serverName ?? ''; } else if (security === 'reality') { const reality = stream.realitySettings as { serverNames?: string[]; serverName?: string } | undefined; if (Array.isArray(reality?.serverNames)) { serverName = reality.serverNames.join(', '); } else if (reality?.serverName) { serverName = reality.serverName; } } return { protocol: dbInbound.protocol, clients, settings, isTcp: network === 'tcp', isWs: network === 'ws', isHttpupgrade: network === 'httpupgrade', isXHTTP: network === 'xhttp', isGrpc: network === 'grpc', isSSMultiUser: isSSMultiUserHelper({ protocol: dbInbound.protocol, settings: settings as { method?: string }, }), isSS2022: isSS2022Helper({ protocol: dbInbound.protocol, settings: settings as { method?: string }, }), isVlessTlsFlow: canEnableTlsFlow({ protocol: dbInbound.protocol, streamSettings: { network, security }, }), host: readNetworkHost(stream, network), path: readNetworkPath(stream, network), serviceName: grpcSettings?.serviceName ?? '', serverName, stream: { network, security, xhttp: xhttpSettings ? { mode: xhttpSettings.mode } : undefined, grpc: grpcSettings ? { multiMode: grpcSettings.multiMode } : undefined, }, }; } interface InboundInfoModalProps { open: boolean; onClose: () => void; dbInbound: DBInboundLike | null; clientIndex?: number; remarkModel?: string; expireDiff?: number; trafficDiff?: number; ipLimitEnable?: boolean; tgBotEnable?: boolean; nodeAddress?: string; subSettings?: SubSettings; lastOnlineMap?: Record; } function copyText(value: unknown, t: (k: string) => string) { ClipboardManager.copyText(String(value ?? '')).then((ok) => { if (ok) getMessage().success(t('copied')); }); } function downloadText(content: string, filename: string) { FileManager.downloadTextFile(content, filename); } function statsColor(stats: ClientStats, trafficDiff: number) { return ColorUtils.usageColor(stats.up + stats.down, trafficDiff, stats.total); } function formatIpInfo(record: unknown) { if (record == null) return ''; if (typeof record === 'string' || typeof record === 'number') return String(record); const r = record as { ip?: string; IP?: string; timestamp?: number | string; Timestamp?: number | string }; const ip = r.ip || r.IP || ''; const ts = r.timestamp || r.Timestamp || 0; if (!ip) return String(record); if (!ts) return String(ip); const date = new Date(Number(ts) * 1000); const timeStr = date .toLocaleString('en-GB', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }) .replace(',', ''); return `${ip} (${timeStr})`; } export default function InboundInfoModal({ open, onClose, dbInbound, clientIndex = 0, remarkModel = '-io', expireDiff = 0, trafficDiff = 0, ipLimitEnable = false, tgBotEnable = false, nodeAddress = '', subSettings, lastOnlineMap = {}, }: InboundInfoModalProps) { const { t } = useTranslation(); const { datepicker } = useDatepicker(); const [inbound, setInbound] = useState(null); const [clientSettings, setClientSettings] = useState(null); const [clientStats, setClientStats] = useState(null); const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]); const [wireguardConfigs, setWireguardConfigs] = useState([]); const [wireguardLinks, setWireguardLinks] = useState([]); const [subLink, setSubLink] = useState(''); const [subJsonLink, setSubJsonLink] = useState(''); const [refreshing, setRefreshing] = useState(false); const [clientIpsArray, setClientIpsArray] = useState([]); const [clientIpsText, setClientIpsText] = useState(''); const [activeTab, setActiveTab] = useState('client'); const loadClientIps = useCallback(async () => { if (!clientStats?.email) return; setRefreshing(true); try { const msg = await HttpUtil.post(`/panel/api/clients/ips/${clientStats.email}`); if (!msg?.success) { setClientIpsText((msg?.obj as string) || 'No IP record'); setClientIpsArray([]); return; } let ips: unknown = msg.obj; if (typeof ips === 'string') { try { ips = JSON.parse(ips); } catch { setClientIpsText(String(ips)); setClientIpsArray([String(ips)]); return; } } if (ips && !Array.isArray(ips) && typeof ips === 'object') ips = [ips]; if (Array.isArray(ips) && ips.length > 0) { const arr = (ips as unknown[]).map(formatIpInfo).filter(Boolean) as string[]; setClientIpsArray(arr); setClientIpsText(arr.join(' | ')); } else { setClientIpsArray([]); setClientIpsText(String(ips || t('tgbot.noIpRecord'))); } } finally { setRefreshing(false); } }, [clientStats, t]); const clearClientIps = useCallback(async () => { if (!clientStats?.email) return; const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${clientStats.email}`); if (msg?.success) { setClientIpsArray([]); setClientIpsText(t('tgbot.noIpRecord')); } }, [clientStats, t]); useEffect(() => { if (!open || !dbInbound) return; const info = buildInboundInfo(dbInbound); setInbound(info); setActiveTab(info.clients.length > 0 ? 'client' : 'inbound'); const idx = clientIndex ?? 0; const clientSet = info.clients.length > 0 ? (info.clients[idx] || null) : null; setClientSettings(clientSet); const stats = clientSet ? (dbInbound.clientStats || []).find((s) => s.email === clientSet.email) || null : null; setClientStats(stats); const inboundForLinks = inboundFromDb(dbInbound); const fallbackHostname = window.location.hostname; if (info.protocol === Protocols.WIREGUARD) { setWireguardConfigs( genWireguardConfigs({ inbound: inboundForLinks, remark: dbInbound.remark, remarkModel: '-io', hostOverride: nodeAddress, fallbackHostname, }).split('\r\n'), ); setWireguardLinks( genWireguardLinks({ inbound: inboundForLinks, remark: dbInbound.remark, remarkModel: '-io', hostOverride: nodeAddress, fallbackHostname, }).split('\r\n'), ); setLinks([]); } else { setLinks( genAllLinks({ inbound: inboundForLinks, remark: dbInbound.remark, remarkModel, client: (clientSet ?? {}) as Parameters[0]['client'], hostOverride: nodeAddress, fallbackHostname, }), ); setWireguardConfigs([]); setWireguardLinks([]); } if (clientSet?.subId) { setSubLink((subSettings?.subURI || '') + clientSet.subId); setSubJsonLink( subSettings?.subJsonEnable ? (subSettings?.subJsonURI || '') + clientSet.subId : '', ); } else { setSubLink(''); setSubJsonLink(''); } setClientIpsArray([]); setClientIpsText(''); if (ipLimitEnable && (clientSet?.limitIp ?? 0) > 0 && stats?.email) { void HttpUtil.post(`/panel/api/clients/ips/${stats.email}`).then((msg) => { if (!msg?.success) { setClientIpsText((msg?.obj as string) || 'No IP record'); return; } let ips: unknown = msg.obj; if (typeof ips === 'string') { try { ips = JSON.parse(ips); } catch { setClientIpsText(String(ips)); setClientIpsArray([String(ips)]); return; } } if (ips && !Array.isArray(ips) && typeof ips === 'object') ips = [ips]; if (Array.isArray(ips) && ips.length > 0) { const arr = (ips as unknown[]).map(formatIpInfo).filter(Boolean) as string[]; setClientIpsArray(arr); setClientIpsText(arr.join(' | ')); } else { setClientIpsText(String(ips || t('tgbot.noIpRecord'))); } }); } }, [open, dbInbound, clientIndex, remarkModel, nodeAddress, subSettings, ipLimitEnable, t]); const isEnable = useMemo(() => { if (clientSettings) return !!clientSettings.enable; return dbInbound?.enable ?? true; }, [clientSettings, dbInbound]); const isDepleted = useMemo(() => { if (!clientStats || !clientSettings) return false; const total = clientStats.total ?? 0; const used = (clientStats.up ?? 0) + (clientStats.down ?? 0); if (total > 0 && used >= total) return true; const expiry = clientSettings.expiryTime ?? 0; if (expiry > 0 && Date.now() >= expiry) return true; return false; }, [clientStats, clientSettings]); const remainingStats = useMemo(() => { if (!clientStats || !clientSettings) return '-'; const remained = clientStats.total - clientStats.up - clientStats.down; return remained > 0 ? SizeFormatter.sizeFormat(remained) : '-'; }, [clientStats, clientSettings]); const formatLastOnline = useCallback( (email: string) => { const ts = lastOnlineMap[email]; if (!ts) return '-'; return IntlUtil.formatDate(ts, datepicker); }, [lastOnlineMap, datepicker], ); const networkLabel = inbound?.stream?.network || ''; const securityLabel = inbound?.stream?.security || 'none'; const securityColor = securityLabel === 'none' ? 'red' : 'green'; const encryptionLabel = (inbound?.settings?.encryption as string) || ''; const serverNameLabel = inbound?.serverName || ''; const showClientTab = !!clientSettings; const showSubscriptionTab = !!(subSettings?.enable && clientSettings?.subId); if (!dbInbound || !inbound) { return ( ); } const clientTab = ( <> {clientSettings?.id && ( )} {dbInbound.isVMess && ( )} {inbound.isVlessTlsFlow && ( )} {clientSettings?.password && ( )} {clientStats && ( )} {clientSettings?.comment && ( )} {ipLimitEnable && ( )} {ipLimitEnable && (clientSettings?.limitIp ?? 0) > 0 && ( )}
{t('pages.inbounds.email')} {clientSettings?.email ? ( {clientSettings.email} ) : ( {t('none')} )}
ID{clientSettings.id}
{t('security')}{clientSettings?.security}
Flow {clientSettings?.flow ? {clientSettings.flow} : {t('none')}}
{t('password')} {clientSettings.password}
{t('status')} {isDepleted ? ( {t('depleted')} ) : isEnable ? ( {t('enabled')} ) : ( {t('disabled')} )}
{t('usage')} {SizeFormatter.sizeFormat(clientStats.up + clientStats.down)} ↑ {SizeFormatter.sizeFormat(clientStats.up)} / {' '}{SizeFormatter.sizeFormat(clientStats.down)} ↓
{t('pages.inbounds.createdAt')} {clientSettings?.created_at ? ( {IntlUtil.formatDate(clientSettings.created_at, datepicker)} ) : -}
{t('pages.inbounds.updatedAt')} {clientSettings?.updated_at ? ( {IntlUtil.formatDate(clientSettings.updated_at, datepicker)} ) : -}
{t('lastOnline')} {formatLastOnline(clientSettings?.email || '')}
{t('comment')}{clientSettings.comment}
{t('pages.inbounds.IPLimit')}{clientSettings?.limitIp ?? 0}
{t('pages.inbounds.IPLimitlog')}
{clientIpsArray.length > 0 ? (
{clientIpsArray.map((item, idx) => ( {item} ))}
) : ( {clientIpsText || t('tgbot.noIpRecord')} )}
loadClientIps()} /> clearClientIps()} />
{t('remained')} {t('pages.inbounds.totalUsage')} {t('pages.inbounds.expireDate')}
{clientStats && (clientSettings?.totalGB ?? 0) > 0 ? ( {remainingStats} ) : !clientSettings?.totalGB || clientSettings.totalGB <= 0 ? ( ) : null} {(clientSettings?.totalGB ?? 0) > 0 ? ( {SizeFormatter.sizeFormat(clientSettings!.totalGB!)} ) : ( )} {(clientSettings?.expiryTime ?? 0) > 0 ? ( {IntlUtil.formatDate(clientSettings!.expiryTime!, datepicker)} ) : (clientSettings?.expiryTime ?? 0) < 0 ? ( {clientSettings!.expiryTime! / -86400000} {t('day')} ) : ( )}
{tgBotEnable && clientSettings?.tgId && ( <> Telegram
{clientSettings.tgId}
)} {hasShareLink(dbInbound.protocol) && links.length > 0 && ( <> {t('pages.inbounds.copyLink')} {links.map((link, idx) => (
{link.remark || `Link ${idx + 1}`}
{link.link}
))} )} {showSubscriptionTab && ( <> {t('subscription.title')}
{t('subscription.title')}
{subLink}
{subSettings?.subJsonEnable && subJsonLink && (
JSON
{subJsonLink}
)} )} ); const inboundTab = ( <>
{t('pages.inbounds.protocol')}
{dbInbound.protocol}
{t('pages.inbounds.address')}
{dbInbound.address}
{t('pages.inbounds.port')}
{dbInbound.port}
{(dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS) && ( <>
{t('transmission')}
{networkLabel}
{(inbound.isTcp || inbound.isWs || inbound.isHttpupgrade || inbound.isXHTTP) && ( <>
{t('host')}
{inbound.host ? {inbound.host} : {t('none')}}
{t('path')}
{inbound.path ? {inbound.path} : {t('none')}}
)} {inbound.isXHTTP && (
Mode
{inbound.stream?.xhttp?.mode}
)} {inbound.isGrpc && ( <>
grpc serviceName
{inbound.serviceName}
grpc multiMode
{String(inbound.stream?.grpc?.multiMode)}
)} )} {hasShareLink(dbInbound.protocol) && ( <>
{t('security')}
{securityLabel}
{encryptionLabel && (
{t('encryption')}
{encryptionLabel}
)} {securityLabel !== 'none' && (
{t('domainName')}
{serverNameLabel ? ( {serverNameLabel} ) : ( {t('none')} )}
)} )}
{dbInbound.isSS && inbound.settings && ( {inbound.isSS2022 && ( )}
{t('encryption')} {inbound.settings.method as string}
{t('password')} {inbound.settings.password as string}
{t('pages.inbounds.network')} {inbound.settings.network as string}
)} {inbound.protocol === Protocols.TUN && inbound.settings && (
Interface name
{inbound.settings.name as string}
MTU
{inbound.settings.mtu as number}
{Array.isArray(inbound.settings.gateway) && (inbound.settings.gateway as string[]).length > 0 && (
Gateway
{(inbound.settings.gateway as string[]).map((ip, j) => ( {ip} ))}
)} {Array.isArray(inbound.settings.dns) && (inbound.settings.dns as string[]).length > 0 && (
DNS
{(inbound.settings.dns as string[]).map((ip, j) => ( {ip} ))}
)}
Outbounds interface
{(inbound.settings.autoOutboundsInterface as string) || 'auto'}
{Array.isArray(inbound.settings.autoSystemRoutingTable) && (inbound.settings.autoSystemRoutingTable as string[]).length > 0 && (
Auto system routes
{(inbound.settings.autoSystemRoutingTable as string[]).map((cidr, j) => ( {cidr} ))}
)}
)} {inbound.protocol === Protocols.TUNNEL && inbound.settings && (
{t('pages.inbounds.targetAddress')}
{inbound.settings.rewriteAddress as string}
{t('pages.inbounds.destinationPort')}
{inbound.settings.rewritePort as number}
{t('pages.inbounds.network')}
{inbound.settings.allowedNetwork as string}
FollowRedirect
{inbound.settings.followRedirect ? t('enabled') : t('disabled')}
)} {dbInbound.isMixed && inbound.settings && (
Auth
{inbound.settings.auth as string}
UDP
{inbound.settings.udp ? t('enabled') : t('disabled')}
{(inbound.settings.ip as string) && (
IP
{inbound.settings.ip as string}
)} {inbound.settings.auth === 'password' && Array.isArray(inbound.settings.accounts) && ( <> {(inbound.settings.accounts as { user: string; pass: string }[]).map((account, idx) => (
{t('username')} #{idx + 1}
{account.user} : {account.pass}
))} )} {inbound.settings.auth === 'noauth' && (
{t('copy')}
)}
)} {dbInbound.isHTTP && Array.isArray(inbound.settings?.accounts) && (inbound.settings!.accounts as unknown[]).length > 0 && (
{(inbound.settings!.accounts as { user: string; pass: string }[]).map((account, idx) => (
{t('username')} #{idx + 1}
{account.user} : {account.pass}
))}
)} {dbInbound.isWireguard && inbound.settings && ( <>
Secret key
{inbound.settings.secretKey as string}
Public key
{inbound.settings.pubKey as string}
MTU
{inbound.settings.mtu as number}
No-kernel TUN
{String(inbound.settings.noKernelTun)}
{Array.isArray(inbound.settings.peers) && (inbound.settings.peers as { privateKey: string; publicKey: string; psk: string; allowedIPs?: string[]; keepAlive?: number }[]).map((peer, idx) => ( Peer {idx + 1}
Secret key
{peer.privateKey}
Public key
{peer.publicKey}
PSK
{peer.psk}
Allowed IPs
{(peer.allowedIPs || []).map((ip, j) => ( {ip} ))}
Keep alive
{peer.keepAlive}
{wireguardConfigs[idx] && (
Peer {idx + 1} config
{wireguardConfigs[idx]}
)} {wireguardLinks[idx] && (
Peer {idx + 1} link
{wireguardLinks[idx]}
)}
))} )} {dbInbound.isSS && !inbound.isSSMultiUser && links.length > 0 && ( <> {t('pages.inbounds.copyLink')} {links.map((link, idx) => (
{link.remark || `Link ${idx + 1}`}
{link.link}
))} )} ); const tabItems = []; if (showClientTab) { tabItems.push({ key: 'client', label: t('pages.inbounds.client'), children: clientTab }); } tabItems.push({ key: 'inbound', label: t('pages.xray.rules.inbound'), children: inboundTab }); return ( ); }