mikuso / ocpp-rpc

A Node.js client & server implementation of the WAMP-like RPC-over-websocket system defined in the OCPP-J protocols.
MIT License
98 stars 29 forks source link
nodejs ocpp ocpp-j ocpp16 ocpp201 rpc websockets

OCPP-RPC

Coverage Status GitHub Workflow Status GitHub issues GitHub license GitHub stars GitHub forks

OCPP-RPC

A client & server implementation of the WAMP-like RPC-over-websocket system defined in the OCPP-J protocols (e.g. OCPP1.6J and OCPP2.0.1J).

Requires Node.js >= 17.3.0

This module is built for Node.js and does not currently work in browsers.

Who is this for?

Features

Table of Contents

Installing

npm install ocpp-rpc

Usage Examples

Barebones OCPP1.6J server

const { RPCServer, createRPCError } = require('ocpp-rpc');

const server = new RPCServer({
    protocols: ['ocpp1.6'], // server accepts ocpp1.6 subprotocol
    strictMode: true,       // enable strict validation of requests & responses
});

server.auth((accept, reject, handshake) => {
    // accept the incoming client
    accept({
        // anything passed to accept() will be attached as a 'session' property of the client.
        sessionId: 'XYZ123'
    });
});

server.on('client', async (client) => {
    console.log(`${client.session.sessionId} connected!`); // `XYZ123 connected!`

    // create a specific handler for handling BootNotification requests
    client.handle('BootNotification', ({params}) => {
        console.log(`Server got BootNotification from ${client.identity}:`, params);

        // respond to accept the client
        return {
            status: "Accepted",
            interval: 300,
            currentTime: new Date().toISOString()
        };
    });

    // create a specific handler for handling Heartbeat requests
    client.handle('Heartbeat', ({params}) => {
        console.log(`Server got Heartbeat from ${client.identity}:`, params);

        // respond with the server's current time.
        return {
            currentTime: new Date().toISOString()
        };
    });

    // create a specific handler for handling StatusNotification requests
    client.handle('StatusNotification', ({params}) => {
        console.log(`Server got StatusNotification from ${client.identity}:`, params);
        return {};
    });

    // create a wildcard handler to handle any RPC method
    client.handle(({method, params}) => {
        // This handler will be called if the incoming method cannot be handled elsewhere.
        console.log(`Server got ${method} from ${client.identity}:`, params);

        // throw an RPC error to inform the server that we don't understand the request.
        throw createRPCError("NotImplemented");
    });
});

await server.listen(3000);

Barebones OCPP1.6J client

const { RPCClient } = require('ocpp-rpc');

const cli = new RPCClient({
    endpoint: 'ws://localhost:3000', // the OCPP endpoint URL
    identity: 'EXAMPLE',             // the OCPP identity
    protocols: ['ocpp1.6'],          // client understands ocpp1.6 subprotocol
    strictMode: true,                // enable strict validation of requests & responses
});

// connect to the OCPP server
await cli.connect();

// send a BootNotification request and await the response
const bootResponse = await cli.call('BootNotification', {
    chargePointVendor: "ocpp-rpc",
    chargePointModel: "ocpp-rpc",
});

// check that the server accepted the client
if (bootResponse.status === 'Accepted') {

    // send a Heartbeat request and await the response
    const heartbeatResponse = await cli.call('Heartbeat', {});
    // read the current server time from the response
    console.log('Server time is:', heartbeatResponse.currentTime);

    // send a StatusNotification request for the controller
    await cli.call('StatusNotification', {
        connectorId: 0,
        errorCode: "NoError",
        status: "Available",
    });
}

Using with Express.js

const {RPCServer, RPCClient} = require('ocpp-rpc');
const express = require('express');

const app = express();
const httpServer = app.listen(3000, 'localhost');

const rpcServer = new RPCServer();
httpServer.on('upgrade', rpcServer.handleUpgrade);

rpcServer.on('client', client => {
    // RPC client connected
    client.call('Say', `Hello, ${client.identity}!`);
});

// create a simple client to connect to the server
const cli = new RPCClient({
    endpoint: 'ws://localhost:3000',
    identity: 'XYZ123'
});

cli.handle('Say', ({params}) => {
    console.log('Server said:', params);
});

await cli.connect();

API Docs

Class: RPCServer

new RPCServer(options)

Event: 'client'

Emitted when a client has connected and been accepted. By default, a client will be automatically accepted if it connects with a matching subprotocol offered by the server (as per the protocols option in the server constructor). This behaviour can be overriden by setting an auth handler.

Event: 'error'

Emitted when the underlying WebSocketServer emits an error.

Event: 'close'

Emitted when the server has fully closed and all clients have been disconnected.

Event: 'closing'

Emitted when the server has begun closing. Beyond this point, no more clients will be accepted and the 'client' event will no longer fire.

Event: 'upgradeAborted'

Emitted when a websocket upgrade has been aborted. This could be caused by an authentication rejection, socket error or websocket handshake error.

server.auth(callback)

Sets an authentication callback to be called before each client is accepted by the server. Setting an authentication callback is optional. By default, clients are accepted if they simply support a matching subprotocol.

The callback function is called with the following three arguments:

Example:

const rpcServer = new RPCServer();
rpcServer.auth((accept, reject, handshake, signal) => {
    if (handshake.identity === 'TEST') {
        accept();
    } else {
        reject(401, "I don't recognise you");
    }
});

server.handleUpgrade(request, socket, head)

Converts an HTTP upgrade request into a WebSocket client to be handled by this RPCServer. This method is bound to the server instance, so it is suitable to pass directly as an http.Server's 'upgrade' event handler.

This is typically only needed if you are creating your own HTTP server. HTTP servers created by listen() have their 'upgrade' event attached to this method automatically.

Example:

const rpcServer = new RPCServer();
const httpServer = http.createServer();
httpServer.on('upgrade', rpcServer.handleUpgrade);

server.reconfigure(options)

Use this method to change any of the options that can be passed to the RPCServer's constructor.

server.listen([port[, host[, options]]])

Creates a simple HTTP server which only accepts websocket upgrades and returns a 404 response to any other request.

Returns a Promise which resolves to an instance of http.Server or rejects with an Error on failure.

server.close([options])

This blocks new clients from connecting, calls client.close() on all connected clients, and then finally closes any listening HTTP servers which were created using server.listen().

Returns a Promise which resolves when the server has completed closing.

Class: RPCClient

new RPCClient(options)

Event: 'badMessage'

This event is emitted when a "bad message" is received. A "bad message" is simply one which does not structurally conform to the RPC protocol or violates some other principle of the framework (such as a response to a call which was not made). If appropriate, the client will respond with a "RpcFrameworkError" or similar error code (depending upon the violation) as required by the spec.

(To be clear, this event will not simply be emitted upon receipt of an error response or invalid call. The message itself must actually be non-conforming to the spec to be considered "bad".)

If too many bad messages are received in succession, the client will be closed with a close code of 1002. The number of bad messages tolerated before automatic closure is determined by the maxBadMessages option. After receiving a valid (non-bad) message, the "bad message" counter will be reset.

Event: 'strictValidationFailure'

This event is emitted in strict mode when an inbound call or outbound response does not satisfy the subprotocol schema validator. See Effects of strictMode to understand what happens in response to the invalid message.

Event: 'call'

Emitted immediately before a call request is sent, or in the case of an inbound call, immediately before the call is processed. Useful for logging or debugging.

If you want to handle (and respond) to the call, you should register a handler using client.handle() instead.

Event: 'callResult'

Emitted immediately after a call result is successfully sent or received. Useful for logging or debugging.

Event: 'callError'

Emitted immediately after a call error is sent or received. Useful for logging or debugging.

Will not be emitted if NOREPLY is sent as a response, or if a call times out.

Event: 'close'

Emitted after client.close() completes.

Event: 'closing'

Emitted when the client is closing and does not plan to reconnect.

Event: 'connecting'

Emitted when the client is trying to establish a new WebSocket connection. If sucessful, the this should be followed by an 'open' event.

Event: 'disconnect'

Emitted when the underlying WebSocket has disconnected. If the client is configured to reconnect, this should be followed by a 'connecting' event, otherwise a 'closing' event.

Event: 'message'

Emitted whenever a message is sent or received over client's WebSocket. Useful for logging or debugging.

If you want to handle (and respond) to a call, you should register a handler using client.handle() instead.

Event: 'open'

Emitted when the client is connected to the server and ready to send & receive calls.

Event: 'ping'

Emitted when the client has received a response to a ping.

Event: 'protocol'

Emitted when the client protocol has been set. Once set, this cannot change. This event only occurs once per connect().

Event: 'response'

Emitted immediately before a response request is sent, or in the case of an inbound response, immediately before the response is processed. Useful for logging or debugging.

Event: 'socketError'

Emitted when the underlying WebSocket instance fires an 'error' event.

client.identity

The decoded client identity.

client.state

The client's state. See state lifecycle

Enum Value
CONNECTING 0
OPEN 1
CLOSING 2
CLOSED 3

client.protocol

The agreed subprotocol. Once connected for the first time, this subprotocol becomes fixed and will be expected upon automatic reconnects (even if the server changes the available subprotocol options).

client.reconfigure(options)

Use this method to change any of the options that can be passed to the RPCClient's constructor.

When changing identity, the RPCClient must be explicitly close()d and then connect()ed for the change to take effect.

client.removeHandler([method])

Unregisters a call handler. If no method name is provided, it will unregister the wildcard handler instead.

client.removeAllHandlers()

Unregisters all previously-registered call handlers (including wildcard handler if set).

client.connect()

The client will attempt to connect to the RPCServer specified in options.url.

Returns a Promise which will either resolve to a result object upon successfully connecting, or reject if the connection fails.

client.sendRaw(message)

Send arbitrary data across the websocket. Not intended for general use.

client.close([options])

Close the underlying connection. Unless awaitPending is true, all in-flight outbound calls will be instantly rejected and any inbound calls in process will have their signal aborted. Unless force is true, close() will wait until all calls are settled before returning the final code and reason for closure.

Returns a Promise which resolves to an Object with properties code and reason.

In some circumstances, the final code and reason returned may be different from those which were requested. For instance, if close() is called twice, the first code provided is canonical. Also, if close() is called while in the CONNECTING state during the first connect, the code will always be 1001, with the reason of 'Connection aborted'.

client.handle([method,] handler)

Registers a call handler. Only one "wildcard" handler can be registered at once. Likewise, attempting to register a handler for a method which is already being handled will override the former handler.

When the handler function is invoked, it will be passed an object with the following properties:

Responses to handled calls are sent according to these rules:

Example of handling an OCPP1.6 Heartbeat
client.handle('Heartbeat', ({reply}) => {
    reply({ currentTime: new Date().toISOString() });
});

// or...
client.handle('Heartbeat', () => {
    return { currentTime: new Date().toISOString() };
});
Example of using NOREPLY
const {NOREPLY} = require('ocpp-rpc');

client.handle('WontReply', ({reply}) => {
    reply(NOREPLY);
});

// or...
client.handle('WontReply', () => {
    return NOREPLY;
});

client.call(method[, params[, options]])

Calls a remote method. Returns a Promise which either:

If the underlying connection is interrupted while waiting for a response, the Promise will reject with an Error.

It's tempting to set callTimeoutMs to Infinity but this could be a mistake; If the remote handler never returns a response, the RPC communications will be blocked as soon as callConcurrency is exhausted (which is 1 by default). (While this is still an unlikely outcome when using this module for both client and server components - interoperability with real world systems can sometimes be unpredictable.)

Class: RPCServerClient : RPCClient

The RPCServerClient is a subclass of RPCClient. This represents an RPCClient from the server's perspective. It has all the same properties and methods as RPCClient but with a couple of additional properties...

client.handshake

This property holds information collected during the WebSocket connection handshake.

client.session

This property can be anything. This is the value passed to accept() during the authentication callback.

createValidator(subprotocol, schema)

Returns a Validator object which can be used for strict mode.

Class: RPCError : Error

An error representing a violation of the RPC protocol.

Throwing an RPCError from within a registered handler will pass the RPCError back to the caller.

To create an RPCError, it is recommended to use the utility method createRPCError().

err.rpcErrorCode

The OCPP-J RPC error code.

err.details

An object containing additional error details.

createRPCError(type[, message[, details]])

This is a utility function to create a special type of RPC Error to be thrown from a call handler to return a non-generic error response.

Returns an RPCError which corresponds to the specified type:

Type Description
GenericError A generic error when no more specific error is appropriate.
NotImplemented Requested method is not known.
NotSupported Requested method is recognised but not supported.
InternalError An internal error occurred and the receiver was not able to process the requested method successfully.
ProtocolError Payload for method is incomplete.
SecurityError During the processing of method a security issue occurred preventing receiver from completing the method successfully.
FormatViolation Payload for the method is syntactically incorrect or not conform the PDU structure for the method.
FormationViolation [Deprecated] Same as FormatViolation. Retained for backwards compatibility with OCPP versions 1.6 and below.
PropertyConstraintViolation Payload is syntactically correct but at least one field contains an invalid value.
OccurrenceConstraintViolation Payload for the method is syntactically correct but at least one of the fields violates occurence constraints.
OccurenceConstraintViolation [Deprecated] Same as OccurrenceConstraintViolation. Retained for backwards compatibility with OCPP versions 1.6 and below.
TypeConstraintViolation Payload for the method is syntactically correct but at least one of the fields violates data type constraints.
MessageTypeNotSupported A message with a Message Type Number received is not supported by this implementation.
RpcFrameworkError Content of the call is not a valid RPC Request, for example: MessageId could not be read.

Strict Validation

RPC clients can operate in "strict mode", validating calls & responses according to subprotocol schemas. The goal of strict mode is to eliminate the possibility of invalid data structures being sent through RPC.

To enable strict mode, pass strictMode: true in the options to the RPCServer or RPCClient constructor. Alternately, you can limit strict mode to specific protocols by passing an array for strictMode instead. The schema ultimately used for validation is determined by whichever subprotocol is agreed between client and server.

Examples:

// enable strict mode for all subprotocols
const server = new RPCServer({
    protocols: ['ocpp1.6', 'ocpp2.0.1'],
    strictMode: true,
});
// only enable strict mode for ocpp1.6
const server = new RPCServer({
    protocols: ['ocpp1.6', 'proprietary0.1'],
    strictMode: ['ocpp1.6'],
});

Effects of strictMode

As a caller, strictMode has the following effects:

As a callee, strictMode has the following effects:

In all cases, a 'strictValidationFailure' event will be emitted, detailing the circumstances of the failure.

Important: If you are using strictMode, you are strongly encouraged to listen for 'strictValidationFailure' events, otherwise you may not know if your responses or inbound calls are being dropped for failing validation.

Supported validation schemas

This module natively supports the following validation schemas:

Subprotocol
ocpp1.6
ocpp2.0.1

Adding additional validation schemas

If you want to use strictMode with a subprotocol which is not included in the list above, you will need to add the appropriate schemas yourself. To do this, you must create a Validator for each subprotocol and pass them to the RPC constructor using the strictModeValidators option. (It is also possible to override the built-in validators this way.)

To create a Validator, you should pass the name of the subprotocol and a well-formed json schema to createValidator(). An example of a well-formed schema can be found at ./lib/schemas/ocpp1_6.json or ./lib/schemas/ocpp2_0_1.json or in the example below.

Example:

// define a validator for subprotocol 'echo1.0'
const echoValidator = createValidator('echo1.0', [
    {
        $schema: "http://json-schema.org/draft-07/schema",
        $id: "urn:Echo.req",
        type: "object",
        properties: {
            val: { type: "string" }
        },
        additionalProperties: false,
        required: ["val"]
    },
    {
        $schema: "http://json-schema.org/draft-07/schema",
        $id: "urn:Echo.conf",
        type: "object",
        properties: {
            val: { type: "string" }
        },
        additionalProperties: false,
        required: ["val"]
    }
]);

const server = new RPCServer({
    protocols: ['echo1.0'],
    strictModeValidators: [echoValidator],
    strictMode: true,
});

/*
client.call('Echo', {val: 'foo'}); // returns {val: foo}
client.call('Echo', ['bar']); // throws RPCError
*/

Once created, the Validator is immutable and can be reused as many times as is required.

OCPP Security

It is possible to achieve all levels of OCPP security using this module. Keep in mind though that many aspects of OCPP security (such as key management, certificate generation, etc...) are beyond the scope of this module and it will be up to you to implement them yourself.

Security Profile 1

This security profile requires HTTP Basic Authentication. Clients are able to provide a HTTP basic auth password via the password option of the RPCClient constructor. Servers are able to validate the password within the callback passed to auth().

Client & Server Example

const cli = new RPCClient({
    identity: "AzureDiamond",
    password: "hunter2",
});

const server = new RPCServer();
server.auth((accept, reject, handshake) => {
    if (handshake.identity === "AzureDiamond" && handshake.password.toString('utf8') === "hunter2") {
        accept();
    } else {
        reject(401);
    }
});

await server.listen(80);
await cli.connect();

A note on identities containing colons

This module supports HTTP Basic auth slightly differently than how it is specified in RFC7617. In that spec, it is made clear that usernames cannot contain colons (:) as a colon is used to delineate where a username ends and a password begins.

In the context of OCPP, the basic-auth username must always be equal to the client's identity. However, since OCPP does not forbid colons in identities, this can possibly lead to a conflict and unexpected behaviours.

In practice, it's not uncommon to see violations of RFC7617 in the wild. All major browsers allow basic-auth usernames to contain colons, despite the fact that this won't make any sense to the server; RFC7617 acknowledges this fact in its text. The established solution to this problem seems to be to simply ignore it.

However, in OCPP, since we have the luxury of knowing that the username must always be equal to the client's identity, it is no longer necessary to rely upon a colon to delineate the username from the password. This module makes use of this guarantee to enable identities and passwords to contain as many or as few colons as you wish.

Additionally, the OCPP security whitepaper recommends passwords consist purely of random bytes (for maximum entropy), although this violates the Basic Auth RFC which requires all passwords to be TEXT (US-ASCII compatible with no control characters). For this reason, this library will not make any presumptions about the character encoding (or otherwise) of the password provided, and present the password as a Buffer.

const { RPCClient, RPCServer } = require('ocpp-rpc');

const cli = new RPCClient({
    identity: "this:is:ok",
    password: "as:is:this",
});

const server = new RPCServer();
server.auth((accept, reject, handshake) => {
    console.log(handshake.identity);                  // "this:is:ok"
    console.log(handshake.password.toString('utf8')); // "as:is:this"
    accept();
});

await server.listen(80);
await cli.connect();

If you prefer to use the more conventional (broken) way of parsing the authorization header using something like the basic-auth module, you can do that too.

const auth = require('basic-auth');

const cli = new RPCClient({
    identity: "this:is:broken",
    password: "as:is:this",
});

const server = new RPCServer();
server.auth((accept, reject, handshake) => {
    const cred = auth.parse(handshake.headers.authorization);

    console.log(cred.name);                  // "this"
    console.log(cred.pass.toString('utf8')); // "is:broken:as:is:this"
    accept();
});

await server.listen(80);
await cli.connect();

Security Profile 2

This security profile requires that the central system offers a TLS-secured endpoint in addition to HTTP Basic Authentication (as per profile 1).

When implementing TLS, keep in mind that OCPP specifies a minimum TLS version and minimum set of cipher suites for maximal compatibility and security. Node.js natively supports this minimum set of requirements, but there's a couple of things you should keep in mind:

TLS Client Example

const { RPCClient } = require('ocpp-rpc');

const cli = new RPCClient({
    endpoint: 'wss://localhost',
    identity: 'EXAMPLE',
    password: 'monkey1',
    wsOpts: { minVersion: 'TLSv1.2' }
});

await cli.connect();

TLS Server Example

Implementing TLS on the server can be achieved in a couple of different ways. The most direct way is to create an HTTPS server, giving you full end-to-end control over the TLS connectivity.

const https = require('https');
const { RPCServer } = require('ocpp-rpc');
const { readFile } = require('fs/promises');

const server = new RPCServer();

const httpsServer = https.createServer({
    cert: [
        await readFile('./server.crt', 'utf8'), // RSA certificate
        await readFile('./ec_server.crt', 'utf8'), // ECDSA certificate
    ],
    key: [
        await readFile('./server.key', 'utf8'), // RSA key
        await readFile('./ec_server.key', 'utf8'), // ECDSA key
    ],
    minVersion: 'TLSv1.2', // require TLS >= v1.2
});

httpsServer.on('upgrade', server.handleUpgrade);
httpsServer.listen(443);

server.auth((accept, reject, handshake) => {
    const tlsClient = handshake.request.client;

    if (!tlsClient) {
        return reject();
    }

    console.log(`${handshake.identity} connected using TLS:`, {
        password: handshake.password, // the HTTP auth password
        cert: tlsClient.getCertificate(), // the certificate used by the server
        cipher: tlsClient.getCipher(), // the cipher suite
        version: tlsClient.getProtocol(), // the TLS version
    });
    accept();
});

Alternatively, your TLS endpoint might be terminated at a different service (e.g. an Ingress controller in a Kubernetes environment or a third-party SaaS reverse-proxy such as Cloudflare). In this case, you may either try to manage your server's TLS through configuration of the aforementioned service, or perhaps by inspecting trusted HTTP headers appended to the request by a proxy.

Security Profile 3

This security profile requires a TLS-secured central system and client-side certificates; This is also known as "Mutual TLS" (or "mTLS" for short).

The client-side example is fairly straight-forward:

mTLS Client Example

const { RPCClient } = require('ocpp-rpc');
const { readFile } = require('fs/promises');

// Read PEM-encoded certificate & key
const cert = await readFile('./client.crt', 'utf8');
const key = await readFile('./client.key', 'utf8');

const cli = new RPCClient({
    endpoint: 'wss://localhost',
    identity: 'EXAMPLE',
    wsOpts: { cert, key, minVersion: 'TLSv1.2' }
});

await cli.connect();

mTLS Server Example

This example is very similar to the example for security profile 2, except for these changes:

Note: If the client does not present a certificate (or the presented certificate is invalid), getPeerCertificate() will return an empty object instead.

const https = require('https');
const { RPCServer } = require('ocpp-rpc');
const { readFile } = require('fs/promises');

const server = new RPCServer();

const httpsServer = https.createServer({
    cert: [
        await readFile('./server.crt', 'utf8'), // RSA certificate
        await readFile('./ec_server.crt', 'utf8'), // ECDSA certificate
    ],
    key: [
        await readFile('./server.key', 'utf8'), // RSA key
        await readFile('./ec_server.key', 'utf8'), // ECDSA key
    ],
    minVersion: 'TLSv1.2', // require TLS >= v1.2
    requestCert: true, // ask client for a certificate
});

httpsServer.on('upgrade', server.handleUpgrade);
httpsServer.listen(443);

server.auth((accept, reject, handshake) => {
    const tlsClient = handshake.request.client;

    if (!tlsClient) {
        return reject();
    }

    console.log(`${handshake.identity} connected using TLS:`, {
        clientCert: tlsClient.getPeerCertificate(), // the certificate used by the client
        serverCert: tlsClient.getCertificate(), // the certificate used by the server
        cipher: tlsClient.getCipher(), // the cipher suite
        version: tlsClient.getProtocol(), // the TLS version
    });

    accept();
});

RPCClient state lifecycle

RPCClient state lifecycle

CLOSED

CONNECTING

OPEN

CLOSING

Upgrading from 1.X -> 2.0

Breaking changes:

License

MIT