UNPKG

@citrineos/util

Version:

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

434 lines 21.1 kB
"use strict"; // Copyright Contributors to the CitrineOS Project // // SPDX-License-Identifier: Apache 2.0 /* eslint-disable */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.WebsocketNetworkConnection = void 0; const base_1 = require("@citrineos/base"); const http = __importStar(require("http")); const https = __importStar(require("https")); const fs_1 = __importDefault(require("fs")); const ws_1 = require("ws"); const tslog_1 = require("tslog"); class WebsocketNetworkConnection { constructor(config, cache, authenticator, router, logger) { this._identifierConnections = new Map(); this._cache = cache; this._config = config; this._logger = logger ? logger.getSubLogger({ name: this.constructor.name }) : new tslog_1.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((websocketServerConfig) => { let _httpServer; switch (websocketServerConfig.securityProfile) { case 3: // mTLS case 2: // TLS _httpServer = https.createServer(this._generateServerOptions(websocketServerConfig), this._onHttpRequest.bind(this)); break; case 1: case 0: default: // No TLS _httpServer = http.createServer(this._onHttpRequest.bind(this)); break; } // TODO: stop using handleProtocols and switch to shouldHandle or verifyClient; see https://github.com/websockets/ws/issues/1552 let _socketServer = new ws_1.WebSocketServer({ noServer: true, handleProtocols: (protocols, req) => this._handleProtocols(protocols, req, websocketServerConfig.protocol), clientTracking: false, }); _socketServer.on('connection', (ws, req) => this._onConnection(ws, websocketServerConfig.id, websocketServerConfig.pingInterval, req)); _socketServer.on('error', (wss, error) => this._onError(wss, error)); _socketServer.on('close', (wss) => this._onClose(wss)); _httpServer.on('upgrade', (request, socket, head) => this._upgradeRequest(request, socket, head, _socketServer, websocketServerConfig)); _httpServer.on('error', (error) => _socketServer.emit('error', error)); // socketServer.close() will not do anything; use httpServer.close() _httpServer.on('close', () => _socketServer.emit('close')); const protocol = websocketServerConfig.securityProfile > 1 ? 'wss' : 'ws'; _httpServer.listen(websocketServerConfig.port, websocketServerConfig.host, () => { this._logger.info(`WebsocketServer running on ${protocol}://${websocketServerConfig.host}:${websocketServerConfig.port}/`); }); 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. */ sendMessage(identifier, message) { return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { var _a; try { const clientConnection = yield this._cache.get(identifier, base_1.CacheNamespace.Connections); if (clientConnection) { const websocketConnection = this._identifierConnections.get(identifier); if (websocketConnection && websocketConnection.readyState === ws_1.WebSocket.OPEN) { websocketConnection.send(message, (error) => { if (error) { reject(error); // Reject the promise with the error } else { resolve(); // Resolve the promise with true indicating success } }); } else { const errorMsg = 'Websocket connection is not ready - ' + identifier; this._logger.fatal(errorMsg); websocketConnection === null || websocketConnection === void 0 ? void 0 : websocketConnection.close(1011, errorMsg); reject(new Error(errorMsg)); // Reject with a new error } } else { 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); (_a = this._identifierConnections .get(identifier)) === null || _a === void 0 ? void 0 : _a.close(1011, 'Failed to get connection information for ' + identifier); reject(new Error(errorMsg)); // Reject with a new error } } catch (error) { reject(error); } })); } shutdown() { return __awaiter(this, void 0, void 0, function* () { this._httpServersMap.forEach((server) => server.close()); yield this._router.shutdown(); }); } /** * 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.`); } } _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. */ _upgradeRequest(req, socket, head, wss, websocketServerConfig) { return __awaiter(this, void 0, void 0, function* () { var _a; // 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); try { const { identifier } = yield this._authenticator.authenticate(req, { securityProfile: websocketServerConfig.securityProfile, allowUnknownChargingStations: websocketServerConfig.allowUnknownChargingStations, }); 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} **/ ((_a = error === null || error === void 0 ? void 0 : error.terminateConnection) === null || _a === void 0 ? void 0 : _a.call(error, socket)) || this._terminateConnectionInternalError(socket); this._logger.warn(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(); } /** * 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, wsServerProtocol) { // Only supports configured protocol version if (protocols.has(wsServerProtocol)) { return wsServerProtocol; } this._logger.error(`Protocol mismatch. Charger supports: [${[...protocols].join(', ')}], but server expects: '${wsServerProtocol}'.`); // 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 {number} pingInterval - The ping interval in seconds. * @param {IncomingMessage} req - The request object associated with the connection. * @return {void} */ _onConnection(ws, websocketServerId, pingInterval, req) { return __awaiter(this, void 0, void 0, function* () { var _a; if (!ws.protocol) { this._logger.debug('Websocket connection without protocol'); return; } else { // Pause the WebSocket event emitter until broker is established ws.pause(); const identifier = this._getClientIdFromUrl(req.url); this._identifierConnections.set(identifier, ws); try { // Get IP address of client const ip = ((_a = req.headers['x-forwarded-for']) === null || _a === void 0 ? void 0 : _a.toString().split(',')[0].trim()) || req.socket.remoteAddress || 'N/A'; const port = req.socket.remotePort; this._logger.info('Client websocket connected', identifier, ip, port, ws.protocol); // Register client const websocketConnection = { id: websocketServerId, protocol: ws.protocol, }; let registered = yield this._cache.set(identifier, JSON.stringify(websocketConnection), base_1.CacheNamespace.Connections); registered = registered && (yield this._router.registerConnection(identifier, ws.protocol)); if (!registered) { this._logger.fatal('Failed to register websocket client', identifier); throw new Error('Failed to register websocket client'); } this._logger.info('Successfully connected new charging station.', identifier); // 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 for the connection. * @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); }; ws.once('close', () => { // Unregister client this._logger.info('Connection closed for', identifier); this._cache.remove(identifier, base_1.CacheNamespace.Connections); this._identifierConnections.delete(identifier); this._router.deregisterConnection(identifier); }); ws.on('ping', (message) => __awaiter(this, void 0, void 0, function* () { this._logger.debug(`Ping received for ${identifier} with message ${JSON.stringify(message)}`); ws.pong(message); })); ws.on('pong', () => __awaiter(this, void 0, void 0, function* () { this._logger.debug('Pong received for', identifier); const clientConnection = yield this._cache.get(identifier, base_1.CacheNamespace.Connections); if (clientConnection) { // Remove expiration for connection and send ping to client in pingInterval seconds. yield this._cache.set(identifier, clientConnection, base_1.CacheNamespace.Connections); this._ping(identifier, ws, pingInterval); } else { this._logger.debug('Pong received for', identifier, 'but client is not alive'); ws.close(1011, 'Client is not alive'); } })); this._ping(identifier, ws, pingInterval); } /** * 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); } /** * 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 milliseconds. * @return {void} This function does not return anything. */ _ping(identifier, ws, pingInterval) { return __awaiter(this, void 0, void 0, function* () { setTimeout(() => __awaiter(this, void 0, void 0, function* () { const clientConnection = yield this._cache.get(identifier, base_1.CacheNamespace.Connections); if (clientConnection) { this._logger.debug('Pinging client', identifier); // Set connection expiration and send ping to client yield this._cache.set(identifier, clientConnection, base_1.CacheNamespace.Connections, pingInterval * 2); ws.ping(); } else { ws.close(1011, 'Client is not alive'); } }), pingInterval * 1000); }); } /** * * @param url Http upgrade request url used by charger * @returns Charger identifier */ _getClientIdFromUrl(url) { return url.split('/').pop(); } _generateServerOptions(config) { const serverOptions = { key: fs_1.default.readFileSync(config.tlsKeyFilePath), cert: fs_1.default.readFileSync(config.tlsCertificateChainFilePath), }; if (config.rootCACertificateFilePath) { serverOptions.ca = fs_1.default.readFileSync(config.rootCACertificateFilePath); } if (config.securityProfile > 2) { serverOptions.requestCert = true; serverOptions.rejectUnauthorized = true; } else { serverOptions.rejectUnauthorized = false; } return serverOptions; } } exports.WebsocketNetworkConnection = WebsocketNetworkConnection; //# sourceMappingURL=WebsocketNetworkConnection.js.map