UNPKG

@citrineos/util

Version:

The OCPP util module which supplies helpful utilities like cache and queue connectors, etc.

557 lines 27 kB
import { CacheNamespace, createIdentifier, getCacheTenantPathMappingKey, getStationIdFromIdentifier, getTenantIdFromIdentifier, } from '@citrineos/base'; import fs from 'fs'; import * as http from 'http'; import * as https from 'https'; import { Duplex } from 'stream'; import { Logger } from 'tslog'; import { WebSocket, WebSocketServer } from 'ws'; import { UpgradeAuthenticationError } from './authenticator/errors/AuthenticationError.js'; export class WebsocketNetworkConnection { _cache; _config; _logger; _identifierConnections = new Map(); _pingTimers = new Map(); _pongTimeouts = new Map(); _closeHandlers = new Map(); // tenantId as key and number of active connections as value _tenantConnectionCounts = new Map(); // websocketServers id as key and http server as value _httpServersMap; _authenticator; _router; _connectionManager; _doesChargingStationExistByStationId; _getMaxChargingStationsForTenant; constructor(config, cache, authenticator, router, logger, doesChargingStationExistByStationId, getMaxChargingStationsForTenant, connectionManager) { this._getMaxChargingStationsForTenant = getMaxChargingStationsForTenant; this._cache = cache; this._config = config; this._doesChargingStationExistByStationId = doesChargingStationExistByStationId; this._connectionManager = connectionManager; this._logger = logger ? logger.getSubLogger({ name: this.constructor.name }) : new Logger({ name: this.constructor.name }); this._authenticator = authenticator; router.networkHook = this.sendMessage.bind(this); this._router = router; this._httpServersMap = new Map(); this._config.util.networkConnection.websocketServers.forEach(async (websocketServerConfig) => { const _httpServer = await this._createAndStartWebsocketServer(websocketServerConfig); this._httpServersMap.set(websocketServerConfig.id, _httpServer); }); } /** * Send a message to the charging station specified by the identifier. * * @param {string} identifier - The identifier of the client. * @param {string} message - The message to send. * @return {void} rejects the promise if message fails to send, otherwise returns void. */ async sendMessage(identifier, message) { const clientConnection = await this._cache.get(identifier, CacheNamespace.Connections); if (!clientConnection) { const errorMsg = 'Cannot identify client connection for ' + identifier; // This can happen when a charging station disconnects in the moment a message is trying to send. // Retry logic on the message sender might not suffice as charging station might connect to different instance. this._logger.error(errorMsg); this._identifierConnections.get(identifier)?.terminate(); throw new Error(errorMsg); } const websocketConnection = this._identifierConnections.get(identifier); if (!websocketConnection) { const errorMsg = 'Websocket connection not found for ' + identifier; this._logger.fatal(errorMsg); throw new Error(errorMsg); } if (websocketConnection.readyState !== WebSocket.OPEN) { const errorMsg = 'Websocket connection is not ready - ' + identifier; this._logger.fatal(errorMsg); websocketConnection.terminate(); throw new Error(errorMsg); } return new Promise((resolve, reject) => { websocketConnection.send(message, (error) => { if (error) { reject(error); } else { resolve(); } }); }); } bindNetworkHook() { return (identifier, message) => this.sendMessage(identifier, message); } async disconnect(tenantId, stationId) { const identifier = createIdentifier(tenantId, stationId); const websocketConnection = this._identifierConnections.get(identifier); if (!websocketConnection) { this._logger.warn(`No websocket connection found for tenantId ${tenantId} and stationId ${stationId}, will still deregister from router.`); } websocketConnection?.close(1000, 'Disconnected by admin request'); const deregistered = await this._router?.deregisterConnection(tenantId, stationId); return !!websocketConnection && deregistered; } async shutdown() { // Deregister all connections before closing servers const websocketClosePromises = []; for (const [identifier, ws] of this._identifierConnections) { // Remove the listener so closing the socket doesn't trigger it const closeHandler = this._closeHandlers.get(identifier); if (closeHandler) { ws.removeListener('close', closeHandler); this._closeHandlers.delete(identifier); } ws.close(1001, 'Server shutting down'); // Now manually call it and await it websocketClosePromises.push(this._handleWebsocketClose(identifier)); } await Promise.all(websocketClosePromises); this._httpServersMap.forEach((server) => server.close()); } /** * Updates certificates for a specific server with the provided TLS key, certificate chain, and optional * root CA. * * @param {string} serverId - The ID of the server to update. * @param {string} tlsKey - The TLS key to set. * @param {string} tlsCertificateChain - The TLS certificate chain to set. * @param {string} [rootCA] - The root CA to set (optional). * @return {void} void */ updateTlsCertificates(serverId, tlsKey, tlsCertificateChain, rootCA) { let httpsServer = this._httpServersMap.get(serverId); if (httpsServer && httpsServer instanceof https.Server) { const secureContextOptions = { key: tlsKey, cert: tlsCertificateChain, }; if (rootCA) { secureContextOptions.ca = rootCA; } httpsServer.setSecureContext(secureContextOptions); this._logger.info(`Updated TLS certificates in SecureContextOptions for server ${serverId}`); } else { throw new TypeError(`Server ${serverId} is not a https server.`); } } /** * Dynamically adds a new websocket server at runtime and starts it. * * @param {WebsocketServerConfig} websocketServerConfig * @returns {Promise<void>} */ async addWebsocketServer(websocketServerConfig) { const httpServer = await this._createAndStartWebsocketServer(websocketServerConfig); this._httpServersMap.set(websocketServerConfig.id, httpServer); } _onHttpRequest(req, res) { if (req.method === 'GET' && req.url === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'healthy' })); } else { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ message: `Route ${req.method}:${req.url} not found`, error: 'Not Found', statusCode: 404, })); } } /** * Method to validate websocket upgrade requests and pass them to the socket server. * * @param {IncomingMessage} req - The request object. * @param {Duplex} socket - Websocket duplex stream. * @param {Buffer} head - Websocket buffer. * @param {WebSocketServer} wss - Websocket server. * @param {WebsocketServerConfig} websocketServerConfig - websocket server config. */ async _upgradeRequest(req, socket, head, wss, websocketServerConfig) { // Failed mTLS and TLS requests are rejected by the server before getting this far this._logger.debug('On upgrade request', req.method, req.url, req.headers, websocketServerConfig); if (this._connectionManager && !this._connectionManager.isConnected()) { this._logger.warn('Rejecting websocket upgrade: message broker is not connected.'); this._terminateConnectionServiceUnavailable(socket); return; } try { // Resolve tenant at upgrade time (query param, path segment, header), // falling back to the server-configured tenant if none provided. const resolvedTenantId = websocketServerConfig.dynamicTenantResolution ? await this._extractTenantIdFromRequest(req, websocketServerConfig) : websocketServerConfig.tenantId; if (resolvedTenantId === undefined) { throw new UpgradeAuthenticationError('Tenant resolution failed: no valid tenant path provided in request and server is not configured with a default tenantId'); } // Attach resolved tenant to request so downstream handlers (connection) can use it req.__resolvedTenantId = resolvedTenantId; const { identifier } = await this._authenticator.authenticate(req, resolvedTenantId, { securityProfile: websocketServerConfig.securityProfile, allowUnknownChargingStations: websocketServerConfig.allowUnknownChargingStations, ignoreAuthenticationHeaders: websocketServerConfig.ignoreAuthenticationHeaders || false, }); this._logger.debug('Successfully registered websocket client', identifier); wss.handleUpgrade(req, socket, head, (ws) => { wss.emit('connection', ws, req); }); } catch (error) { /** * See {@link IUpgradeError.terminateConnection} **/ error?.terminateConnection?.(socket) || this._terminateConnectionInternalError(socket); this._logger.warn('Connection upgrade failed', error); } } /** * Utility function to reject websocket upgrade requests with 500 status code. * @param socket - Websocket duplex stream. */ _terminateConnectionInternalError(socket) { socket.write('HTTP/1.1 500 Internal Server Error\r\n'); socket.write('\r\n'); socket.end(); socket.destroy(); } _terminateConnectionServiceUnavailable(socket) { socket.write('HTTP/1.1 503 Service Unavailable\r\n'); socket.write('\r\n'); socket.end(); socket.destroy(); } /** * Internal method to handle new client connection and ensures supported protocols are used. * * @param {Set<string>} protocols - The set of protocols to handle. * @param {IncomingMessage} _req - The request object. * @param {string} wsServerProtocol - The websocket server protocol. * @return {boolean|string} - Returns the protocol version if successful, otherwise false. */ _handleProtocols(protocols, _req, wsServerProtocols) { // Only supports configured protocol version for (const wsServerProtocol of wsServerProtocols) { if (protocols.has(wsServerProtocol)) { return wsServerProtocol; } } this._logger.error(`Protocol mismatch. Charger supports: [${[...protocols].join(', ')}], but server expects: '${wsServerProtocols.join(', ')}'.`); // Reject the client trying to connect return false; } /** * Internal method to handle the connection event when a WebSocket connection is established. * This happens after successful protocol exchange with client. * * @param {WebSocket} ws - The WebSocket object representing the connection. * @param {WebsocketServerConfig} websocketServerConfig - The websocket server configuration. * @param {number} pingInterval - The ping interval in seconds. * @param {IncomingMessage} req - The request object associated with the connection. * @return {void} */ async _onConnection(ws, websocketServerConfig, pingInterval, req) { if (!ws.protocol) { this._logger.warn('Websocket connection without protocol'); ws.close(1002, 'Protocol not specified'); return; } else { // Pause the WebSocket event emitter until broker is established ws.pause(); const stationId = this._getClientIdFromUrl(req.url); // Prefer tenant resolved during upgrade; fallback to server-configured tenant. const tenantId = req.__resolvedTenantId ?? websocketServerConfig.tenantId; const checker = this._doesChargingStationExistByStationId ?? this._router.doesChargingStationExistByStationId?.bind(this._router); if (!checker) { throw new Error('No method available to check if charging station exists'); } const exists = await checker(tenantId, stationId); if (!exists && !websocketServerConfig.allowUnknownChargingStations) { this._logger.error('Rejecting connection: station %s not found in tenant %s', stationId, tenantId); ws.close(1011, 'Unknown charging station'); return; } const identifier = createIdentifier(tenantId, stationId); // Enforce per-tenant connection limit from the tenant's maxChargingStations field if (this._getMaxChargingStationsForTenant) { const maxConnections = await this._getMaxChargingStationsForTenant(tenantId); if (typeof maxConnections === 'number' && maxConnections > 0) { const currentCount = this._tenantConnectionCounts.get(tenantId) ?? 0; if (currentCount >= maxConnections) { this._logger.warn(`Tenant ${tenantId} exceeded max connections (${maxConnections}), rejecting ${identifier}`); ws.close(1013, 'Tenant connection limit exceeded'); return; } } } const staleWs = this._identifierConnections.get(identifier); if (staleWs) { staleWs.terminate(); try { await this._router.deregisterConnection(tenantId, stationId); } catch (err) { this._logger.error(`Failed to deregister stale connection for ${identifier}`, err); } this._logger.warn(`Terminated stale websocket connection for ${identifier}`); } this._identifierConnections.set(identifier, ws); this._tenantConnectionCounts.set(tenantId, (this._tenantConnectionCounts.get(tenantId) ?? 0) + 1); try { // Get IP address of client const ip = req.headers['x-forwarded-for']?.toString().split(',')[0].trim() || req.socket.remoteAddress || 'N/A'; const port = req.socket.remotePort; const connLogger = this._logger.getSubLogger({ name: `T${tenantId}:${stationId}`, }); connLogger.info('Client websocket connected', identifier, ip, port, ws.protocol); // Register client const websocketConnection = { id: websocketServerConfig.id, protocol: ws.protocol, }; let registered = await this._cache.set(identifier, JSON.stringify(websocketConnection), CacheNamespace.Connections, pingInterval * 3); registered = registered && (await this._router.registerConnection(tenantId, stationId, ws.protocol)); if (!registered) { connLogger.fatal('Failed to register websocket client', identifier); throw new Error('Failed to register websocket client'); } connLogger.info(`Successfully connected new charging station: ${identifier} live connections: ${this._identifierConnections.size}`); // Register all websocket events this._registerWebsocketEvents(identifier, ws, pingInterval); // Resume the WebSocket event emitter after events have been subscribed to ws.resume(); } catch (error) { this._logger.fatal('Failed to subscribe to message broker for ', identifier); ws.close(1011, 'Failed to subscribe to message broker for ' + identifier); } } } /** * Internal method to register event listeners for the WebSocket connection. * * @param {string} identifier - The unique identifier of the connection, i.e. the combination of tenantId and stationId. * @param {WebSocket} ws - The WebSocket object representing the connection. * @param {number} pingInterval - The ping interval in seconds. * @return {void} This function does not return anything. */ _registerWebsocketEvents(identifier, ws, pingInterval) { ws.onerror = (event) => { this._logger.error('Connection error encountered for', identifier, event.error, event.message, event.type); ws.close(1011, event.message); }; ws.onmessage = (event) => { this._onMessage(identifier, event.data.toString(), ws.protocol); }; const closeHandler = () => { this._handleWebsocketClose(identifier); }; ws.once('close', closeHandler); this._closeHandlers.set(identifier, closeHandler); ws.on('ping', (message) => { this._logger.debug('Ping received for', identifier, 'with message', message); ws.pong(message); }); ws.on('pong', () => { this._logger.debug('Pong received for', identifier); // Disarm the pong-timeout — the client is alive. const pongTimeout = this._pongTimeouts.get(identifier); if (pongTimeout) { clearTimeout(pongTimeout); this._pongTimeouts.delete(identifier); } this._ping(identifier, ws, pingInterval, false); }); this._ping(identifier, ws, pingInterval, true); } /** * Internal method to handle the incoming message from the websocket client. * * @param {string} identifier - The client identifier. * @param {string} message - The incoming message from the client. * @param {OCPPVersionType} protocol - The OCPP protocol version of the client, 'ocpp1.6' or 'ocpp2.0.1'. * @return {void} This function does not return anything. */ _onMessage(identifier, message, protocol) { this._router.onMessage(identifier, message, new Date(), protocol); } async _handleWebsocketClose(identifier) { this._closeHandlers.delete(identifier); // Cancel any pending ping timer so it doesn't fire against a closed socket const timer = this._pingTimers.get(identifier); if (timer) { clearTimeout(timer); this._pingTimers.delete(identifier); } const pongTimeout = this._pongTimeouts.get(identifier); if (pongTimeout) { clearTimeout(pongTimeout); this._pongTimeouts.delete(identifier); } const closedTenantId = getTenantIdFromIdentifier(identifier); // Unregister client await Promise.all([ this._cache.remove(identifier, CacheNamespace.Connections), this._router.deregisterConnection(closedTenantId, getStationIdFromIdentifier(identifier)), ]); const prevCount = this._tenantConnectionCounts.get(closedTenantId) ?? 0; if (prevCount <= 1) { this._tenantConnectionCounts.delete(closedTenantId); } else { this._tenantConnectionCounts.set(closedTenantId, prevCount - 1); } this._identifierConnections.delete(identifier); this._logger.info(`Connection closed for ${identifier} live connections: ${this._identifierConnections.size}`); } /** * Internal method to handle the error event for the WebSocket server. * * @param {WebSocketServer} wss - The WebSocket server instance. * @param {Error} error - The error object. * @return {void} This function does not return anything. */ _onError(wss, error) { this._logger.error(error); // TODO: Try to recover the Websocket server } /** * Internal method to handle the event when the WebSocketServer is closed. * * @param {WebSocketServer} wss - The WebSocketServer instance. * @return {void} This function does not return anything. */ _onClose(wss) { this._logger.debug('Websocket Server closed'); // TODO: Try to recover the Websocket server } /** * Internal method to execute a ping operation on a WebSocket connection after a delay of 60 seconds. * * @param {string} identifier - The identifier of the client connection. * @param {WebSocket} ws - The WebSocket connection to ping. * @param {number} pingInterval - The ping interval in seconds. * @param {boolean} applyJitter - Whether to apply jitter to the ping interval. * @return {void} This function does not return anything. */ _ping(identifier, ws, pingInterval, applyJitter) { const jitter = applyJitter ? Math.random() * pingInterval * 1000 : 0; const sendTimer = setTimeout(() => { this._pingTimers.delete(identifier); const pongTimeout = setTimeout(() => { this._logger.debug('Pong timeout for', identifier, '— terminating'); this._pongTimeouts.delete(identifier); ws.terminate(); }, pingInterval * 1000); this._pongTimeouts.set(identifier, pongTimeout); this._logger.debug('Pinging client', identifier); ws.ping(); }, pingInterval * 1000 + jitter); this._pingTimers.set(identifier, sendTimer); this._cache .updateExpiration(identifier, pingInterval * 3, CacheNamespace.Connections) .catch((error) => { this._logger.error('Failed to update cache expiration - will close websocket for', identifier, error); ws.close(1011, 'Failed to update cache expiration'); }); } /** * * @param url Http upgrade request url used by charger * @returns Charger identifier */ _getClientIdFromUrl(url) { // Remove query string first const pathOnly = url.split('?')[0]; return pathOnly.split('/').pop(); } /** * Extract tenant id from the incoming upgrade request. * Supported sources (in order): query `tenant`/`tenantId`, header `x-tenant-id`, * path segment (second-last segment if URL is `/tenant/station`). */ async _extractTenantIdFromRequest(req, config) { try { const rawUrl = req.url ?? ''; const url = new URL(rawUrl, 'http://localhost'); const segments = url.pathname.split('/').filter(Boolean); // Path segment mapping: assume /.../{pathSegment}/{station} // We look for a mapping of pathSegment to tenantId. if (segments.length >= 2 && config.tenantPathMapping) { const pathSegment = segments[segments.length - 2]; const cachedTenantIdString = await this._cache.get(getCacheTenantPathMappingKey(config.id, pathSegment), CacheNamespace.TenantPathMapping); if (!cachedTenantIdString) { this._logger.debug(`No mapping found for path segment: ${pathSegment}`); } return cachedTenantIdString ? Number(cachedTenantIdString) : undefined; } } catch (err) { // If parsing fails, ignore and fall back to server-configured tenant this._logger.debug('Failed to extract tenant from request', err); } return undefined; } _generateServerOptions(config) { const serverOptions = { key: fs.readFileSync(config.tlsKeyFilePath), cert: fs.readFileSync(config.tlsCertificateChainFilePath), }; if (config.rootCACertificateFilePath) { serverOptions.ca = fs.readFileSync(config.rootCACertificateFilePath); } if (config.securityProfile > 2) { serverOptions.requestCert = true; serverOptions.rejectUnauthorized = true; } else { serverOptions.rejectUnauthorized = false; } return serverOptions; } _createAndStartWebsocketServer(wsConfig) { for (const [key, value] of Object.entries(wsConfig.tenantPathMapping ?? {})) { this._cache.set(getCacheTenantPathMappingKey(wsConfig.id, key), value.toString(), CacheNamespace.TenantPathMapping); } return new Promise((resolve) => { let httpServer; switch (wsConfig.securityProfile) { case 3: // mTLS case 2: // TLS httpServer = https.createServer(this._generateServerOptions(wsConfig), this._onHttpRequest.bind(this)); break; case 1: case 0: default: httpServer = http.createServer(this._onHttpRequest.bind(this)); break; } const wss = new WebSocketServer({ noServer: true, handleProtocols: (protocols, req) => this._handleProtocols(protocols, req, wsConfig.protocols), clientTracking: false, }); wss.on('connection', (ws, req) => this._onConnection(ws, wsConfig, wsConfig.pingInterval, req)); wss.on('error', (server, error) => this._onError(server, error)); wss.on('close', (server) => this._onClose(server)); httpServer.on('upgrade', (req, socket, head) => this._upgradeRequest(req, socket, head, wss, wsConfig)); httpServer.on('error', (error) => wss.emit('error', error)); httpServer.on('close', () => wss.emit('close')); const protocol = wsConfig.securityProfile > 1 ? 'wss' : 'ws'; httpServer.listen(wsConfig.port, wsConfig.host, () => { this._logger.info(`WebsocketServer running on ${protocol}://${wsConfig.host}:${wsConfig.port}/`); resolve(httpServer); }); }); } } //# sourceMappingURL=WebsocketNetworkConnection.js.map