@cliz/inlets
Version:
Cloud Native Tunnel
346 lines (345 loc) • 17.5 kB
JavaScript
;
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;