hm-simulator 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/servers.js CHANGED
@@ -1,8 +1,11 @@
1
1
  'use strict';
2
2
 
3
3
  const {EventEmitter} = require('node:events');
4
+ const net = require('node:net');
5
+ const {Duplex, PassThrough} = require('node:stream');
4
6
 
5
7
  const binrpc = require('binrpc');
8
+ const binrpcProtocol = require('binrpc/lib/protocol.js');
6
9
  const xmlrpc = require('homematic-xmlrpc');
7
10
 
8
11
  const {encodeResponse, encodeFault} = require('./binrpc-codec.js');
@@ -35,9 +38,16 @@ function createBinrpcServer({host, port, dispatch, onListening, onError}) {
35
38
  return;
36
39
  }
37
40
 
38
- dispatch(method, params, (fault, result) => {
39
- socket.write(fault ? encodeFault(fault) : encodeResponse(result));
40
- });
41
+ dispatch(
42
+ method,
43
+ params,
44
+ (fault, result) => {
45
+ if (!socket.destroyed) {
46
+ socket.write(fault ? encodeFault(fault) : encodeResponse(result));
47
+ }
48
+ },
49
+ {socket},
50
+ );
41
51
  };
42
52
 
43
53
  server.on('error', (error) => {
@@ -61,6 +71,22 @@ function unauthorized(response, realm) {
61
71
  response.end('Unauthorized');
62
72
  }
63
73
 
74
+ const LATIN1 = /^(iso-?8859-1|latin-?1|l1|us-ascii|windows-1252|cp1252)$/i;
75
+
76
+ /**
77
+ * An XML-RPC request body as UTF-8: converted from ISO-8859-1 when the Content-Type header or the
78
+ * XML declaration says so, as it is.
79
+ * @param {Buffer} body
80
+ * @param {string} [contentType]
81
+ * @returns {Buffer}
82
+ */
83
+ function toUtf8(body, contentType) {
84
+ const header = /charset=["']?([\w-]+)/i.exec(contentType || '');
85
+ const prolog = /^\s*<\?xml[^>]*encoding=["']([\w-]+)["']/i.exec(body.subarray(0, 200).toString('latin1'));
86
+ const charset = (prolog && prolog[1]) || (header && header[1]);
87
+ return charset && LATIN1.test(charset) ? Buffer.from(body.toString('latin1'), 'utf8') : body;
88
+ }
89
+
64
90
  /**
65
91
  * xmlrpc server, optionally with TLS and/or HTTP basic auth.
66
92
  *
@@ -79,7 +105,7 @@ function unauthorized(response, realm) {
79
105
  * @param {function} [options.onListening]
80
106
  * @returns {object} the homematic-xmlrpc Server instance
81
107
  */
82
- function createXmlrpcServer({host, port, dispatch, tls, auth, path, onListening, onError}) {
108
+ function createXmlrpcServer({host, port, dispatch, tls, auth, path, onListening, onError, listen = true}) {
83
109
  const serverOptions = tls ? {host, port, ...tls} : {host, port};
84
110
  const listening = () => {
85
111
  if (typeof onListening === 'function') {
@@ -90,21 +116,64 @@ function createXmlrpcServer({host, port, dispatch, tls, auth, path, onListening,
90
116
  ? xmlrpc.createSecureServer(serverOptions, listening)
91
117
  : xmlrpc.createServer(serverOptions, listening);
92
118
 
119
+ if (!listen) {
120
+ // the connections come from createDualServer, see there; homematic-xmlrpc listens on the
121
+ // next tick, so this is in time
122
+ server.httpServer.listen = function () {
123
+ return this;
124
+ };
125
+ }
126
+
93
127
  server.listenerCount = function (eventName) {
94
128
  return INTERNAL_EVENTS.has(eventName) ? EventEmitter.prototype.listenerCount.call(this, eventName) : 1;
95
129
  };
96
130
 
131
+ // homematic-xmlrpc reads every request as UTF-8, but the interface processes speak ISO-8859-1
132
+ // and so do clients that talk to them (openccu-lite's lite-rpc proxy sends it declared in the
133
+ // prolog): the body is read here and handed on as UTF-8. It also emits the method call
134
+ // synchronously from the body's 'end'; a listener registered before its own runs first and
135
+ // tells the dispatch which connection the call came on.
136
+ let current = null;
137
+ const httpServer = server.httpServer;
138
+ const [handleRequest] = httpServer.listeners('request');
139
+ httpServer.removeAllListeners('request');
140
+ httpServer.on('request', (request, response) => {
141
+ const chunks = [];
142
+ request.on('data', (chunk) => chunks.push(chunk));
143
+ request.on('end', () => {
144
+ const body = new PassThrough();
145
+ Object.assign(body, {
146
+ headers: request.headers,
147
+ url: request.url,
148
+ method: request.method,
149
+ socket: request.socket,
150
+ });
151
+ body.once('end', () => {
152
+ current = {socket: request.socket};
153
+ });
154
+ handleRequest(body, response);
155
+ body.end(toUtf8(Buffer.concat(chunks), request.headers['content-type']));
156
+ });
157
+ });
158
+
97
159
  const originalEmit = server.emit;
98
160
  server.emit = function (eventName, ...args) {
99
161
  if (!INTERNAL_EVENTS.has(eventName) && args.length === 3 && typeof args[2] === 'function') {
100
162
  const [, params, callback] = args;
101
- dispatch(eventName, params || [], (fault, result) => {
102
- if (fault) {
103
- callback({faultCode: fault.faultCode, faultString: fault.faultString});
104
- } else {
105
- callback(null, result === undefined ? '' : result);
106
- }
107
- });
163
+ const context = current || {};
164
+ current = null;
165
+ dispatch(
166
+ eventName,
167
+ params || [],
168
+ (fault, result) => {
169
+ if (fault) {
170
+ callback({faultCode: fault.faultCode, faultString: fault.faultString});
171
+ } else {
172
+ callback(null, result === undefined ? '' : result);
173
+ }
174
+ },
175
+ context,
176
+ );
108
177
  return true;
109
178
  }
110
179
 
@@ -119,7 +188,6 @@ function createXmlrpcServer({host, port, dispatch, tls, auth, path, onListening,
119
188
  });
120
189
 
121
190
  if (auth || path) {
122
- const httpServer = server.httpServer;
123
191
  const [handleMethodCall] = httpServer.listeners('request');
124
192
  httpServer.removeAllListeners('request');
125
193
  const expected = auth
@@ -147,4 +215,185 @@ function createXmlrpcServer({host, port, dispatch, tls, auth, path, onListening,
147
215
  return server;
148
216
  }
149
217
 
150
- module.exports = {createBinrpcServer, createXmlrpcServer};
218
+ /**
219
+ * Reads BIN-RPC requests from one connection and answers them, as binrpc's server does, but with
220
+ * the simulator's own encoder so that a fault goes out as message type 0xff.
221
+ *
222
+ * @param {net.Socket} socket
223
+ * @param {function} dispatch (method, params, callback) - callback(fault, result)
224
+ * @param {Buffer} [first] bytes already read from the socket
225
+ */
226
+ function serveBinrpc(socket, dispatch, first) {
227
+ let receiver = Buffer.alloc(0);
228
+
229
+ const receive = (data) => {
230
+ receiver = Buffer.concat([receiver, data]);
231
+ while (receiver.length >= 8) {
232
+ const length = receiver.readUInt32BE(4);
233
+ if (receiver.length < length + 8) {
234
+ return;
235
+ }
236
+ const message = receiver.subarray(0, length + 8);
237
+ receiver = receiver.subarray(length + 8);
238
+
239
+ let request;
240
+ try {
241
+ request = binrpcProtocol.decodeRequest(message);
242
+ } catch {
243
+ request = undefined;
244
+ }
245
+ const method = request && request.method;
246
+ if (typeof method !== 'string' || method === '') {
247
+ socket.write(encodeResponse(''));
248
+ continue;
249
+ }
250
+ dispatch(
251
+ method,
252
+ request.params || [],
253
+ (fault, result) => {
254
+ if (!socket.destroyed) {
255
+ socket.write(fault ? encodeFault(fault) : encodeResponse(result));
256
+ }
257
+ },
258
+ {socket},
259
+ );
260
+ }
261
+ };
262
+
263
+ socket.on('data', receive);
264
+ if (first) {
265
+ receive(first);
266
+ }
267
+ }
268
+
269
+ /**
270
+ * A duplex stream over a socket that first yields `head`, the bytes already read from it.
271
+ * @param {net.Socket} socket
272
+ * @param {Buffer} head
273
+ * @returns {Duplex}
274
+ */
275
+ function replay(socket, head) {
276
+ const stream = new Duplex({
277
+ read() {
278
+ socket.resume();
279
+ },
280
+ write(chunk, encoding, callback) {
281
+ socket.write(chunk, encoding, callback);
282
+ },
283
+ final(callback) {
284
+ socket.end();
285
+ callback();
286
+ },
287
+ destroy(error, callback) {
288
+ socket.destroy();
289
+ callback(error);
290
+ },
291
+ });
292
+ stream.push(head);
293
+ socket.on('data', (data) => {
294
+ if (!stream.push(data)) {
295
+ socket.pause();
296
+ }
297
+ });
298
+ socket.on('end', () => stream.push(null));
299
+ socket.on('close', () => stream.destroy());
300
+ return stream;
301
+ }
302
+
303
+ /** First bytes of a BIN-RPC message, and of a TLS handshake record. */
304
+ const BIN = Buffer.from('Bin', 'ascii');
305
+ const TLS_HANDSHAKE = 0x16;
306
+
307
+ /**
308
+ * rfd's and hs485d's port: BIN-RPC and XML-RPC on the same port, as on a CCU.
309
+ *
310
+ * A connection whose first bytes are `Bin` is BIN-RPC; anything else is handed to an HTTP server
311
+ * running the XML-RPC dispatch - with `tls` an HTTPS server, and a connection that does not start
312
+ * with a TLS handshake is closed. BIN-RPC stays plain, and basic auth applies to the XML-RPC half
313
+ * only: BIN-RPC has no such thing.
314
+ *
315
+ * @param {object} options as createXmlrpcServer, plus
316
+ * @param {string[]} [options.protocols=['binrpc', 'xmlrpc']] what this port answers
317
+ * @returns {{server: net.Server, close: function(): Promise<void>, on: function}} an emitter with
318
+ * `server` (the net server) and `close()`, like the other two server kinds
319
+ */
320
+ function createDualServer({host, port, dispatch, tls, auth, onListening, onError, protocols}) {
321
+ const accepts = new Set(protocols || ['binrpc', 'xmlrpc']);
322
+ const emitter = new EventEmitter();
323
+ const connections = new Set();
324
+
325
+ const xmlrpcServer = (secure) =>
326
+ createXmlrpcServer({host, port: 0, dispatch, tls: secure ? tls : null, auth, listen: false});
327
+ // with tls the XML-RPC half is HTTPS only, as the simulator's xmlrpc servers are
328
+ const http = accepts.has('xmlrpc') && !tls ? xmlrpcServer(false) : null;
329
+ const https = accepts.has('xmlrpc') && tls ? xmlrpcServer(true) : null;
330
+
331
+ const server = net.createServer((socket) => {
332
+ connections.add(socket);
333
+ socket.on('close', () => connections.delete(socket));
334
+ socket.on('error', () => {});
335
+
336
+ let head = Buffer.alloc(0);
337
+ const sniff = (data) => {
338
+ head = Buffer.concat([head, data]);
339
+ if (head.length < BIN.length && BIN.subarray(0, head.length).equals(head)) {
340
+ // could still be "Bin": wait for more
341
+ return;
342
+ }
343
+ socket.removeListener('data', sniff);
344
+
345
+ if (head.subarray(0, BIN.length).equals(BIN)) {
346
+ if (accepts.has('binrpc')) {
347
+ serveBinrpc(socket, dispatch, head);
348
+ } else {
349
+ socket.destroy();
350
+ }
351
+ return;
352
+ }
353
+
354
+ if (head[0] === TLS_HANDSHAKE && https) {
355
+ // a TLS socket reads from the connection's handle, past anything unshifted: it
356
+ // gets a stream that starts with the bytes already read
357
+ https.httpServer.emit('connection', replay(socket, head));
358
+ return;
359
+ }
360
+ if (!http) {
361
+ socket.destroy();
362
+ return;
363
+ }
364
+ socket.pause();
365
+ socket.unshift(head);
366
+ http.httpServer.emit('connection', socket);
367
+ socket.resume();
368
+ };
369
+ socket.on('data', sniff);
370
+ });
371
+
372
+ server.on('error', (error) => {
373
+ if (emitter.listenerCount('error') > 0) {
374
+ emitter.emit('error', error);
375
+ }
376
+ if (typeof onError === 'function') {
377
+ onError(error);
378
+ }
379
+ });
380
+
381
+ server.listen(port, host, () => {
382
+ emitter.emit('listening');
383
+ if (typeof onListening === 'function') {
384
+ onListening(emitter);
385
+ }
386
+ });
387
+
388
+ emitter.server = server;
389
+ emitter.close = () =>
390
+ new Promise((resolve) => {
391
+ server.close(() => resolve());
392
+ for (const socket of connections) {
393
+ socket.destroy();
394
+ }
395
+ });
396
+ return emitter;
397
+ }
398
+
399
+ module.exports = {createBinrpcServer, createXmlrpcServer, createDualServer};