UNPKG

@cliz/inlets

Version:
346 lines (345 loc) 17.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createWebSocketMonitor = void 0; const websocket_1 = require("@znode/websocket"); const doreamon_1 = require("@zodash/doreamon"); const cli_1 = require("@cliz/cli"); const hmac = require("@zodash/hmac"); const semver = require("semver"); const utils_1 = require("../../../utils"); const type_1 = require("../../common/type"); const protocol_adapter_1 = require("../../common/protocol-adapter"); const debug = require('debug')('inlets:monitor:ws'); const logger = doreamon_1.default.logger.getLogger('monitor:ws '); const SERVER_CAPABILITIES = { flags: type_1.CapabilityFlags.BINARY_PROTOCOL | type_1.CapabilityFlags.COMPRESSION | type_1.CapabilityFlags.STREAMING | type_1.CapabilityFlags.FLOW_CONTROL | type_1.CapabilityFlags.HTTP_BINARY | type_1.CapabilityFlags.HTTP_STREAMING | type_1.CapabilityFlags.TCP_OVER_WS | type_1.CapabilityFlags.TCP_MULTIPLEX, version: '2.0.0', features: { compression: { algorithms: ['brotli', 'gzip'], }, chunkSize: { min: 1024, max: 1024 * 1024, default: 64 * 1024, }, flowControl: { windowSize: 1024 * 1024, }, }, }; function isLegacyClient(authentication) { return !authentication.capabilities || !authentication.capabilities.flags || authentication.capabilities.flags === 0; } function negotiateCapabilities(clientCapabilities) { var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k; if (!clientCapabilities || !clientCapabilities.flags || clientCapabilities.flags === 0) { return undefined; } const negotiatedFlags = clientCapabilities.flags & SERVER_CAPABILITIES.flags; if (negotiatedFlags === 0) { logger.warn('[capabilities] No common capabilities found, falling back to legacy protocol'); return undefined; } const negotiatedFeatures = {}; if (negotiatedFlags & type_1.CapabilityFlags.COMPRESSION) { const clientAlgorithms = ((_b = (_a = clientCapabilities.features) === null || _a === void 0 ? void 0 : _a.compression) === null || _b === void 0 ? void 0 : _b.algorithms) || []; const serverAlgorithms = ((_d = (_c = SERVER_CAPABILITIES.features) === null || _c === void 0 ? void 0 : _c.compression) === null || _d === void 0 ? void 0 : _d.algorithms) || []; const commonAlgorithms = clientAlgorithms.filter(alg => serverAlgorithms.includes(alg)); if (commonAlgorithms.length > 0) { negotiatedFeatures.compression = { algorithms: commonAlgorithms, preferred: commonAlgorithms.includes('brotli') ? 'brotli' : commonAlgorithms[0], }; } } if (negotiatedFlags & type_1.CapabilityFlags.STREAMING) { const clientChunk = (_e = clientCapabilities.features) === null || _e === void 0 ? void 0 : _e.chunkSize; const serverChunk = (_f = SERVER_CAPABILITIES.features) === null || _f === void 0 ? void 0 : _f.chunkSize; if (clientChunk && serverChunk) { negotiatedFeatures.chunkSize = { min: Math.max(clientChunk.min, serverChunk.min), max: Math.min(clientChunk.max, serverChunk.max), default: Math.min(clientChunk.default || 64 * 1024, serverChunk.default || 64 * 1024), }; } else if (serverChunk) { negotiatedFeatures.chunkSize = serverChunk; } } if (negotiatedFlags & type_1.CapabilityFlags.FLOW_CONTROL) { const clientWindow = ((_h = (_g = clientCapabilities.features) === null || _g === void 0 ? void 0 : _g.flowControl) === null || _h === void 0 ? void 0 : _h.windowSize) || 0; const serverWindow = ((_k = (_j = SERVER_CAPABILITIES.features) === null || _j === void 0 ? void 0 : _j.flowControl) === null || _k === void 0 ? void 0 : _k.windowSize) || 0; negotiatedFeatures.flowControl = { windowSize: Math.min(clientWindow || 1024 * 1024, serverWindow || 1024 * 1024), }; } return { flags: negotiatedFlags, version: SERVER_CAPABILITIES.version, features: negotiatedFeatures, }; } function formatProtocolConfiguration(capabilities) { var _a, _b, _c; if (!capabilities) { return 'legacy (flags=0x0)'; } const capabilityNames = [ { flag: type_1.CapabilityFlags.BINARY_PROTOCOL, label: 'BINARY_PROTOCOL' }, { flag: type_1.CapabilityFlags.COMPRESSION, label: 'COMPRESSION' }, { flag: type_1.CapabilityFlags.STREAMING, label: 'STREAMING' }, { flag: type_1.CapabilityFlags.FLOW_CONTROL, label: 'FLOW_CONTROL' }, { flag: type_1.CapabilityFlags.HTTP_BINARY, label: 'HTTP_BINARY' }, { flag: type_1.CapabilityFlags.HTTP_STREAMING, label: 'HTTP_STREAMING' }, { flag: type_1.CapabilityFlags.TCP_OVER_WS, label: 'TCP_OVER_WS' }, { flag: type_1.CapabilityFlags.TCP_MULTIPLEX, label: 'TCP_MULTIPLEX' }, { flag: type_1.CapabilityFlags.PRIORITY_QUEUE, label: 'PRIORITY_QUEUE' }, { flag: type_1.CapabilityFlags.CONNECTION_POOL, label: 'CONNECTION_POOL' }, ]; const enabledCapabilities = capabilityNames .filter(item => (capabilities.flags & item.flag) === item.flag) .map(item => item.label); const parts = [ `features=[${enabledCapabilities.join(', ') || 'none'}]`, `version=${capabilities.version}`, ]; const featureParts = []; const compression = (_a = capabilities.features) === null || _a === void 0 ? void 0 : _a.compression; if (compression) { const algorithms = compression.algorithms.join('/'); featureParts.push(`compression(${algorithms}${compression.preferred ? `, preferred=${compression.preferred}` : ''})`); } const chunkSize = (_b = capabilities.features) === null || _b === void 0 ? void 0 : _b.chunkSize; if (chunkSize) { featureParts.push(`chunkSize(min=${chunkSize.min}, max=${chunkSize.max}, default=${chunkSize.default})`); } const flowControl = (_c = capabilities.features) === null || _c === void 0 ? void 0 : _c.flowControl; if (flowControl) { featureParts.push(`flowControl(window=${flowControl.windowSize})`); } if (featureParts.length > 0) { parts.push(featureParts.join('; ')); } return parts.join(' | '); } function createWebSocketMonitor(ctx, options) { const emitter = new doreamon_1.default.event.Event(); const version = options.version; const domain = options.domain; const secure = !!options.secure; const token = options.token; const port = +options.port; function getServerUrlBySubDomain(subDomain) { if (!subDomain) return null; if (secure) { return `https://${subDomain}.${domain}`; } return `http://${subDomain}.${domain}:${port}`; } const ws = new websocket_1.default.Server({ path: ctx.config.wsPath, }); ws.on('connection', async (wsSocket) => { logger.info('[tunnel:client] connect ws server ...'); let isAuthenticated = false; let subDomain = null; setTimeout(() => { if (!isAuthenticated && wsSocket.isAlive) { logger.info('[tunnel:client] removed without authorization'); wsSocket.disconnect(); } }, 10 * 1000); wsSocket.on('authenticate', async (authentication) => { var _a, _b; const clientId = authentication.clientId || `anonymous-${doreamon_1.default.uuid().slice(0, 8)}`; logger.info(`[tunnel:client][${clientId}] version: ${authentication.version}`); const clientVersion = authentication.version; const serverVersion = version; if (!semver.gte(clientVersion, serverVersion)) { logger.warn(`[tunnel:client] warning: client version(${clientVersion}) should be larger than or equal with server(${serverVersion}), which may cause tunnel broken.`); wsSocket.emit('warn', `[tunnel:client] warning: client version(${clientVersion}) should be larger than or equal with server(${serverVersion}), which may cause tunnel broken.`); } let signedSecret; let clientConfig; try { const res = await token(authentication.authType, authentication.clientId, { type: authentication.type, }); if (res.authType === 'public' && authentication.subDomain) { const message = 'subDomain is not allowed for public authType'; logger.info(`[tunnel:client][${clientId}] force disconnect - ${message}`); wsSocket.emit('authenticate', { ok: false, message }); return wsSocket.disconnect(); } signedSecret = res.token; clientConfig = res.config; if (!signedSecret) { isAuthenticated = false; const message = `invalid client(${authentication.authType})`; logger.info(`[tunnel:client][${clientId}] force disconnect - ${message} -`, authentication); wsSocket.emit('authenticate', { ok: false, message }); return wsSocket.disconnect(); } } catch (error) { isAuthenticated = false; const errorMessage = error instanceof Error ? error.message : String(error); const message = `invalid client(${errorMessage})`; logger.info(`[tunnel:client][${clientId}] force disconnect - ${message} -`, authentication); wsSocket.emit('authenticate', { ok: false, message }); return wsSocket.disconnect(); } const signature = hmac.hmacSHA512(authentication.timestamp.toString(), signedSecret); if (signature !== authentication.signature) { isAuthenticated = false; const message = `invalid signature`; logger.info(`[tunnel:client][${clientId}] force disconnect - ${message}`); wsSocket.emit('authenticate', { ok: false, message }); return wsSocket.disconnect(); } isAuthenticated = true; logger.info(`[tunnel:client][${clientId}] type: ${authentication.type}`); const negotiatedCapabilities = negotiateCapabilities(authentication.capabilities); const useNewProtocol = !!negotiatedCapabilities; const protocolSummary = formatProtocolConfiguration(negotiatedCapabilities); if (useNewProtocol) { logger.info(`[tunnel:client][${clientId}] Using new protocol`); } else { logger.info(`[tunnel:client][${clientId}] Using legacy protocol`); } logger.info(`[tunnel:client][${clientId}] protocol configuration => ${protocolSummary}`); wsSocket.capabilities = negotiatedCapabilities; wsSocket.useNewProtocol = useNewProtocol; wsSocket.isLegacyClient = isLegacyClient(authentication); const adapter = protocol_adapter_1.ProtocolAdapterFactory.create(wsSocket, negotiatedCapabilities, false); wsSocket.adapter = adapter; const containerId = doreamon_1.default.uuid(); if (authentication.type === 'tcp') { if (authentication.tunnelPort) { logger.info(`[tunnel:client][${clientId}] tunnel port: ${authentication.tunnelPort}`); } if (authentication.tunnelPort && !await cli_1.api.network.isPortAvailable(authentication.tunnelPort)) { isAuthenticated = false; const message = `tunnel tcp port(${authentication.tunnelPort}) has already been used.`; logger.info(`[tunnel:client][${clientId}] force disconnect - ${message}`); wsSocket.emit('authenticate', { ok: false, message }); return wsSocket.disconnect(); } ctx.container.create(containerId, token, wsSocket, authentication); } else if (authentication.type === 'http') { if (!authentication.subDomain) { subDomain = ctx.domainMappings.bindWs(wsSocket); } else { logger.info(`[tunnel:client][${clientId}][domain] request: ${authentication.subDomain}.${domain}`); const hasSubDomain = ctx.domainMappings.has(authentication.subDomain); if (hasSubDomain) { wsSocket.emit('authenticate', { ok: false, message: 'domain id has been used, please use another', }); logger.info(`[tunnel:client][${clientId}][domain] illegal: ${authentication.subDomain}.${domain}`); return wsSocket.disconnect(); } subDomain = ctx.domainMappings.bindWs(wsSocket, authentication.subDomain); } logger.info(`[tunnel:client][${clientId}][domain] ${subDomain}.${domain}`); } else { throw new Error(`unknown authentication type: ${authentication.type}`); } const config = { version, notification: (_a = options.notification) === null || _a === void 0 ? void 0 : _a.config, negotiatedCapabilities, ...clientConfig, }; wsSocket.emit('authenticate', { ok: true, version, url: getServerUrlBySubDomain(subDomain), config, }); wsSocket.containerId = containerId; wsSocket.clientId = clientId; logger.info(`[tunnel:client][${clientId}] authenticated successfully (container: ${containerId})`); ctx.trafficStats.setStartTime(clientId); if (useNewProtocol && wsSocket.adapter) { const adapter = wsSocket.adapter; adapter.onHTTPResponse(async (id, data) => { const [tcpId, requestId] = id.split(':'); const callback = ctx.callbackContainer.get(tcpId, requestId); if (callback) { callback.apply(null, [data.toString('base64')]); } }); } (_b = options === null || options === void 0 ? void 0 : options.notification) === null || _b === void 0 ? void 0 : _b.notify(`[上线] 客户端 - ${clientId}`, [ `客户端版本:${authentication.version}`, `客户端类型:${authentication.type}`, `客户端授权方式:${authentication.authType}`, `客户端端口:${authentication.tunnelPort}`, `当前时间:${doreamon_1.default.date().format('YYYY-MM-DD HH:mm:ss')}` ]); emitter.emit('tunnel', { type: authentication.type, containerId, }); }); wsSocket.on('response', async function onResponse(response) { if (!isAuthenticated) return; const adapter = wsSocket.adapter; const useNewProtocol = wsSocket.useNewProtocol; if (useNewProtocol && adapter) { return; } const [tcpId, requestId] = response.id.split(':'); const callback = ctx.callbackContainer.get(tcpId, requestId); if (callback) { const data = await utils_1.dataProcessor.server.onResponse(response.data); callback.apply(null, [data]); } }); wsSocket.on('disconnect', () => { var _a; const container = ctx.container.get(wsSocket.containerId); const clientId = (container === null || container === void 0 ? void 0 : container.clientId) || wsSocket.clientId || 'unknown'; ctx.trafficStats.setEndTime(clientId); const statsInfo = ctx.trafficStats.formatStats(clientId); logger.info(`[tunnel:client][${clientId}] disconnected - Traffic Stats: ${statsInfo}`); if (subDomain) { ctx.domainMappings.unbindWs(subDomain); } if (!container) { return logger.error(`Cannot get container id: ${wsSocket.containerId}`); } (_a = options === null || options === void 0 ? void 0 : options.notification) === null || _a === void 0 ? void 0 : _a.notify(`[掉线] 客户端 - ${container.clientId || clientId}`, [ `客户端版本:${container.version}`, `客户端类型:${container.type}`, `客户端授权方式:${container.authType}`, `客户端端口:${container.tunnelPort}`, `流量统计:${statsInfo}`, `当前时间:${doreamon_1.default.date().format('YYYY-MM-DD HH:mm:ss')}` ]); }); }); return { ws, emitter, }; } exports.createWebSocketMonitor = createWebSocketMonitor;