ocpp-rpc
Version:
A client & server implementation of the WAMP-like RPC-over-websocket system defined in the OCPP protocols (e.g. OCPP1.6-J and OCPP2.0.1).
321 lines (272 loc) • 12.5 kB
JavaScript
const {EventEmitter, once} = require('events');
const {WebSocketServer, OPEN, CLOSING, CLOSED} = require('ws');
const {createServer} = require('http');
const RPCServerClient = require('./server-client');
const { abortHandshake, parseSubprotocols } = require('./ws-util');
const standardValidators = require('./standard-validators');
const { getPackageIdent } = require('./util');
const { WebsocketUpgradeError } = require('./errors');
class RPCServer extends EventEmitter {
constructor(options) {
super();
this._httpServerAbortControllers = new Set();
this._state = OPEN;
this._clients = new Set();
this._pendingUpgrades = new WeakMap();
this._options = {
// defaults
wssOptions: {},
protocols: [],
callTimeoutMs: 1000*30,
pingIntervalMs: 1000*30,
deferPingsOnActivity: false,
respondWithDetailedErrors: false,
callConcurrency: 1,
maxBadMessages: Infinity,
strictMode: false,
strictModeValidators: [],
};
this.reconfigure(options || {});
this._wss = new WebSocketServer({
...this._options.wssOptions,
noServer: true,
handleProtocols: (protocols, request) => {
const {protocol} = this._pendingUpgrades.get(request);
return protocol;
},
});
this._wss.on('headers', h => h.push(`Server: ${getPackageIdent()}`));
this._wss.on('error', err => this.emit('error', err));
this._wss.on('connection', this._onConnection.bind(this));
}
reconfigure(options) {
const newOpts = Object.assign({}, this._options, options);
if (newOpts.strictMode && !newOpts.protocols?.length) {
throw Error(`strictMode requires at least one subprotocol`);
}
const strictValidators = [...standardValidators];
if (newOpts.strictModeValidators) {
strictValidators.push(...newOpts.strictModeValidators);
}
this._strictValidators = strictValidators.reduce((svs, v) => {
svs.set(v.subprotocol, v);
return svs;
}, new Map());
let strictProtocols = [];
if (Array.isArray(newOpts.strictMode)) {
strictProtocols = newOpts.strictMode;
} else if (newOpts.strictMode) {
strictProtocols = newOpts.protocols;
}
const missingValidator = strictProtocols.find(protocol => !this._strictValidators.has(protocol));
if (missingValidator) {
throw Error(`Missing strictMode validator for subprotocol '${missingValidator}'`);
}
this._options = newOpts;
}
get handleUpgrade() {
return async (request, socket, head) => {
let resolved = false;
const ac = new AbortController();
const {signal} = ac;
const url = new URL('http://localhost' + (request.url || '/'));
const pathParts = url.pathname.split('/');
const identity = decodeURIComponent(pathParts.pop());
const abortUpgrade = (error) => {
resolved = true;
if (error && error instanceof WebsocketUpgradeError) {
abortHandshake(socket, error.code, error.message);
} else {
abortHandshake(socket, 500);
}
if (!signal.aborted) {
ac.abort(error);
this.emit('upgradeAborted', {
error,
socket,
request,
identity,
});
}
};
socket.on('error', (err) => {
abortUpgrade(err);
});
try {
if (this._state !== OPEN) {
throw new WebsocketUpgradeError(500, "Server not open");
}
if (socket.readyState !== 'open') {
throw new WebsocketUpgradeError(400, `Client readyState = '${socket.readyState}'`);
}
const headers = request.headers;
if (headers.upgrade.toLowerCase() !== 'websocket') {
throw new WebsocketUpgradeError(400, "Can only upgrade websocket upgrade requests");
}
const endpoint = pathParts.join('/') || '/';
const remoteAddress = request.socket.remoteAddress;
const protocols = ('sec-websocket-protocol' in request.headers)
? parseSubprotocols(request.headers['sec-websocket-protocol'])
: new Set();
let password;
if (headers.authorization) {
try {
/**
* This is a non-standard basic auth parser because it supports
* colons in usernames (which is normally disallowed).
* However, this shouldn't cause any confusion as we have a
* guarantee from OCPP that the username will always be equal to
* the identity.
* It also supports binary passwords, which is also a spec violation
* but is necessary for allowing truly random binary keys as
* recommended by the OCPP security whitepaper.
*/
const b64up = headers.authorization.match(/^ *(?:[Bb][Aa][Ss][Ii][Cc]) +([A-Za-z0-9._~+/-]+=*) *$/)[1];
const userPassBuffer = Buffer.from(b64up, 'base64');
const clientIdentityUserBuffer = Buffer.from(identity + ':');
if (clientIdentityUserBuffer.compare(userPassBuffer, 0, clientIdentityUserBuffer.length) === 0) {
// first part of buffer matches `${identity}:`
password = userPassBuffer.subarray(clientIdentityUserBuffer.length);
}
} catch (err) {
// failing to parse authorization header is no big deal.
// just leave password as undefined as if no header was sent.
}
}
const handshake = {
remoteAddress,
headers,
protocols,
endpoint,
identity,
query: url.searchParams,
request,
password,
};
const accept = (session, protocol) => {
if (resolved) return;
resolved = true;
try {
if (socket.readyState !== 'open') {
throw new WebsocketUpgradeError(400, `Client readyState = '${socket.readyState}'`);
}
if (protocol === undefined) {
// pick first subprotocol (preferred by server) that is also supported by the client
protocol = (this._options.protocols ?? []).find(p => protocols.has(p));
} else if (protocol !== false && !protocols.has(protocol)) {
throw new WebsocketUpgradeError(400, `Client doesn't support expected subprotocol`);
}
// cache auth results for connection creation
this._pendingUpgrades.set(request, {
session: session ?? {},
protocol,
handshake
});
this._wss.handleUpgrade(request, socket, head, ws => {
this._wss.emit('connection', ws, request);
});
} catch (err) {
abortUpgrade(err);
}
};
const reject = (code = 404, message = 'Not found') => {
if (resolved) return;
resolved = true;
abortUpgrade(new WebsocketUpgradeError(code, message));
};
socket.once('end', () => {
reject(400, `Client connection closed before upgrade complete`);
});
socket.once('close', () => {
reject(400, `Client connection closed before upgrade complete`);
});
if (this.authCallback) {
await this.authCallback(
accept,
reject,
handshake,
signal
);
} else {
accept();
}
} catch (err) {
abortUpgrade(err);
}
};
}
async _onConnection(websocket, request) {
try {
if (this._state !== OPEN) {
throw Error("Server is no longer open");
}
const {handshake, session} = this._pendingUpgrades.get(request);
const client = new RPCServerClient({
identity: handshake.identity,
reconnect: false,
callTimeoutMs: this._options.callTimeoutMs,
pingIntervalMs: this._options.pingIntervalMs,
deferPingsOnActivity: this._options.deferPingsOnActivity,
respondWithDetailedErrors: this._options.respondWithDetailedErrors,
callConcurrency: this._options.callConcurrency,
strictMode: this._options.strictMode,
strictModeValidators: this._options.strictModeValidators,
maxBadMessages: this._options.maxBadMessages,
protocols: this._options.protocols,
}, {
ws: websocket,
session,
handshake,
});
this._clients.add(client);
client.once('close', () => this._clients.delete(client));
this.emit('client', client);
} catch (err) {
websocket.close(err.statusCode || 1000, err.message);
}
}
auth(cb) {
this.authCallback = cb;
}
async listen(port, host, options = {}) {
const ac = new AbortController();
this._httpServerAbortControllers.add(ac);
if (options.signal) {
once(options.signal, 'abort').then(() => {
ac.abort(options.signal.reason);
});
}
const httpServer = createServer({
noDelay: true,
}, (req, res) => {
res.setHeader('Server', getPackageIdent());
res.statusCode = 404;
res.end();
});
httpServer.on('upgrade', this.handleUpgrade);
httpServer.once('close', () => this._httpServerAbortControllers.delete(ac));
await new Promise((resolve, reject) => {
httpServer.listen({
port,
host,
signal: ac.signal,
}, err => err ? reject(err) : resolve());
});
return httpServer;
}
async close({code, reason, awaitPending, force} = {}) {
if (this._state === OPEN) {
this._state = CLOSING;
this.emit('closing');
code = code ?? 1001;
await Array.from(this._clients).map(cli => cli.close({code, reason, awaitPending, force}));
await new Promise((resolve, reject) => {
this._wss.close(err => err ? reject(err) : resolve());
this._httpServerAbortControllers.forEach(ac => ac.abort("Closing"));
});
this._state = CLOSED;
this.emit('close');
}
}
}
module.exports = RPCServer;