@citrineos/util
Version:
The OCPP util module which supplies helpful utilities like cache and queue connectors, etc.
434 lines • 21.1 kB
JavaScript
"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