• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

homebridge / HAP-NodeJS / 17775229178

16 Sep 2025 06:21PM UTC coverage: 63.437%. Remained the same
17775229178

push

github

bwp91
v2.0.2

1743 of 3286 branches covered (53.04%)

Branch coverage included in aggregate %.

6250 of 9314 relevant lines covered (67.1%)

312.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

18.76
/src/lib/datastream/DataStreamServer.ts
1
import assert from "assert";
18✔
2
import crypto from "crypto";
18✔
3
import createDebug from "debug";
18✔
4
import { EventEmitter, EventEmitter as NodeEventEmitter } from "events";
18✔
5
import net, { Socket } from "net";
18✔
6
import { HAPConnection, HAPConnectionEvent } from "../util/eventedhttp";
7

8
import * as hapCrypto from "../util/hapCrypto";
18✔
9
import { DataStreamParser, DataStreamReader, DataStreamWriter, Int64 } from "./DataStreamParser";
18✔
10

11

12
const debug = createDebug("HAP-NodeJS:DataStream:Server");
18✔
13

14
/**
15
 * @group HomeKit Data Streams (HDS)
16
 */
17
export type PreparedDataStreamSession = {
18

19
  connection: HAPConnection, // reference to the hap session which created the request
20

21
  accessoryToControllerEncryptionKey: Buffer,
22
  controllerToAccessoryEncryptionKey: Buffer,
23
  accessoryKeySalt: Buffer,
24

25
  port?: number,
26

27
  connectTimeout?: NodeJS.Timeout, // 10s timer
28

29
}
30

31
/**
32
 * @group HomeKit Data Streams (HDS)
33
 */
34
export type PrepareSessionCallback = (error?: Error, preparedSession?: PreparedDataStreamSession) => void;
35
/**
36
 * @group HomeKit Data Streams (HDS)
37
 */
38
export type EventHandler = (message: Record<any, any>) => void; // eslint-disable-line @typescript-eslint/no-explicit-any
39
/**
40
 * @group HomeKit Data Streams (HDS)
41
 */
42
export type RequestHandler = (id: number, message: Record<any, any>) => void; // eslint-disable-line @typescript-eslint/no-explicit-any
43
/**
44
 * @group HomeKit Data Streams (HDS)
45
 */
46
export type ResponseHandler = (
47
  error: Error | undefined,
48
  status: HDSStatus | undefined,
49
  message: Record<any, any>) => void; // eslint-disable-line @typescript-eslint/no-explicit-any
50
/**
51
 * @group HomeKit Data Streams (HDS)
52
 */
53
export type GlobalEventHandler = (
54
  connection: DataStreamConnection,
55
  message: Record<any, any>) => void; // eslint-disable-line @typescript-eslint/no-explicit-any
56
/**
57
 * @group HomeKit Data Streams (HDS)
58
 */
59
export type GlobalRequestHandler = (
60
  connection: DataStreamConnection, id: number,
61
  message: Record<any, any>) => void; // eslint-disable-line @typescript-eslint/no-explicit-any
62

63
/**
64
 * @group HomeKit Data Streams (HDS)
65
 */
66
export interface DataStreamProtocolHandler {
67
  eventHandler?: Record<string, EventHandler>;
68
  requestHandler?: Record<string, RequestHandler>;
69
}
70

71
/**
72
 * @group HomeKit Data Streams (HDS)
73
 */
74
export const enum Protocols { // a collection of currently known protocols
18✔
75
  CONTROL = "control",
18✔
76
  TARGET_CONTROL = "targetControl",
18✔
77
  DATA_SEND = "dataSend",
18✔
78
}
79

80
/**
81
 * @group HomeKit Data Streams (HDS)
82
 */
83
export const enum Topics { // a collection of currently known topics grouped by their protocol
18✔
84
  // control
85
  HELLO = "hello",
18✔
86

87
  // targetControl
88
  WHOAMI = "whoami",
18✔
89

90
  // dataSend
91
  OPEN = "open",
18✔
92
  DATA = "data",
18✔
93
  ACK = "ack",
18✔
94
  CLOSE = "close",
18✔
95
}
96

97
/**
98
 * @group HomeKit Data Streams (HDS)
99
 */
100
export enum HDSStatus {
18✔
101
  // noinspection JSUnusedGlobalSymbols
102
  SUCCESS = 0,
18✔
103
  OUT_OF_MEMORY = 1,
18✔
104
  TIMEOUT = 2,
18✔
105
  HEADER_ERROR = 3,
18✔
106
  PAYLOAD_ERROR = 4,
18✔
107
  MISSING_PROTOCOL = 5,
18✔
108
  PROTOCOL_SPECIFIC_ERROR = 6,
18✔
109
}
110

111
/**
112
 * @group HomeKit Data Streams (HDS)
113
 */
114
export const enum HDSProtocolSpecificErrorReason { // close reason used in the dataSend protocol
18✔
115
  // noinspection JSUnusedGlobalSymbols
116
  NORMAL = 0,
18✔
117
  NOT_ALLOWED = 1,
18✔
118
  BUSY = 2,
18✔
119
  CANCELLED = 3,
18✔
120
  UNSUPPORTED = 4,
18✔
121
  UNEXPECTED_FAILURE = 5,
18✔
122
  TIMEOUT = 6,
18✔
123
  BAD_DATA = 7,
18✔
124
  PROTOCOL_ERROR = 8,
18✔
125
  INVALID_CONFIGURATION = 9,
18✔
126
}
127

128
/**
129
 * An error indicating a protocol level HDS error.
130
 * E.g. it may be used to encode a {@link HDSStatus.PROTOCOL_SPECIFIC_ERROR} in the {@link Protocols.DATA_SEND} protocol.
131
 * @group HomeKit Data Streams (HDS)
132
 */
133
export class HDSProtocolError extends Error {
18✔
134
  reason: HDSProtocolSpecificErrorReason;
135

136
  /**
137
   * Initializes a new `HDSProtocolError`
138
   * @param reason - The {@link HDSProtocolSpecificErrorReason}.
139
   *  Values MUST NOT be {@link HDSProtocolSpecificErrorReason.NORMAL}.
140
   */
141
  constructor(reason: HDSProtocolSpecificErrorReason) {
142
    super("HDSProtocolError: " + reason);
×
143
    assert(reason !== HDSProtocolSpecificErrorReason.NORMAL, "Cannot initialize a HDSProtocolError with NORMAL!");
×
144
    this.reason = reason;
×
145
  }
146
}
147

148

149
const enum ServerState {
18✔
150
  UNINITIALIZED, // server socket hasn't been created
18✔
151
  BINDING, // server is created and is currently trying to bind
18✔
152
  LISTENING, // server is created and currently listening for new connections
18✔
153
  CLOSING,
18✔
154
}
155

156
const enum ConnectionState {
18✔
157
  UNIDENTIFIED,
18✔
158
  EXPECTING_HELLO,
18✔
159
  READY,
18✔
160
  CLOSING,
18✔
161
  CLOSED,
18✔
162
}
163

164
/**
165
 * @group HomeKit Data Streams (HDS)
166
 */
167
export type HDSFrame = {
168
  header: Buffer,
169
  cipheredPayload: Buffer,
170
  authTag: Buffer,
171
  plaintextPayload?: Buffer,
172
}
173

174
/**
175
 * @group HomeKit Data Streams (HDS)
176
 */
177
export const enum MessageType {
18✔
178
  EVENT = 1,
18✔
179
  REQUEST = 2,
18✔
180
  RESPONSE = 3,
18✔
181
}
182

183
/**
184
 * @group HomeKit Data Streams (HDS)
185
 */
186
export type DataStreamMessage = {
187
  type: MessageType,
188

189
  protocol: string,
190
  topic: string,
191
  id?: number, // for requests and responses
192
  status?: HDSStatus, // for responses
193

194
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
195
  message: Record<any, any>,
196
}
197

198
/**
199
 * @group HomeKit Data Streams (HDS)
200
 */
201
export const enum DataStreamServerEvent {
18✔
202
  /**
203
   * This event is emitted when a new client socket is received. At this point we have no idea to what
204
   * hap session this connection will be matched.
205
   */
206
  CONNECTION_OPENED = "connection-opened",
18✔
207
  /**
208
   * This event is emitted when the socket of a connection gets closed.
209
   */
210
  CONNECTION_CLOSED = "connection-closed",
18✔
211
}
212

213
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
214
export declare interface DataStreamServer {
215
  on(event: "connection-opened", listener: (connection: DataStreamConnection) => void): this;
216
  on(event: "connection-closed", listener: (connection: DataStreamConnection) => void): this;
217

218
  emit(event: "connection-opened", connection: DataStreamConnection): boolean;
219
  emit(event: "connection-closed", connection: DataStreamConnection): boolean;
220
}
221

222
/**
223
 * DataStreamServer which listens for incoming tcp connections and handles identification of new connections
224
 * @group HomeKit Data Streams (HDS)
225
 */
226
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
227
export class DataStreamServer extends EventEmitter {
18✔
228

229
  static readonly version = "1.0";
18✔
230

231
  private state: ServerState = ServerState.UNINITIALIZED;
219✔
232

233
  private static accessoryToControllerInfo = Buffer.from("HDS-Read-Encryption-Key");
18✔
234
  private static controllerToAccessoryInfo = Buffer.from("HDS-Write-Encryption-Key");
18✔
235

236
  private tcpServer?: net.Server;
237
  private tcpPort?: number;
238

239
  preparedSessions: PreparedDataStreamSession[] = [];
219✔
240
  private readonly connections: DataStreamConnection[] = [];
219✔
241
  private removeListenersOnceClosed = false;
219✔
242

243
  private readonly internalEventEmitter: NodeEventEmitter = new NodeEventEmitter(); // used for message event and message request handlers
219✔
244

245
  public constructor() {
246
    super();
219✔
247
  }
248

249
  /**
250
   * Registers a new event handler to handle incoming event messages.
251
   * The handler is only called for a connection if for the give protocol no ProtocolHandler
252
   * was registered on the connection level.
253
   *
254
   * @param protocol - name of the protocol to register the handler for
255
   * @param event - name of the event (also referred to as topic. See {@link Topics} for some known ones)
256
   * @param handler - function to be called for every occurring event
257
   */
258
  public onEventMessage(protocol: string | Protocols, event: string | Topics, handler: GlobalEventHandler): this {
259
    this.internalEventEmitter.on(protocol + "-e-" + event, handler);
×
260
    return this;
×
261
  }
262

263
  /**
264
   * Removes a registered event handler.
265
   *
266
   * @param protocol - name of the protocol to unregister the handler for
267
   * @param event - name of the event (also referred to as topic. See {@link Topics} for some known ones)
268
   * @param handler - registered event handler
269
   */
270
  public removeEventHandler(protocol: string | Protocols, event: string | Topics, handler: GlobalEventHandler): this {
271
    this.internalEventEmitter.removeListener(protocol + "-e-" + event, handler);
×
272
    return this;
×
273
  }
274

275
  /**
276
   * Registers a new request handler to handle incoming request messages.
277
   * The handler is only called for a connection if for the give protocol no ProtocolHandler
278
   * was registered on the connection level.
279
   *
280
   * @param protocol - name of the protocol to register the handler for
281
   * @param request - name of the request (also referred to as topic. See {@link Topics} for some known ones)
282
   * @param handler - function to be called for every occurring request
283
   */
284
  public onRequestMessage(protocol: string | Protocols, request: string | Topics, handler: GlobalRequestHandler): this {
285
    this.internalEventEmitter.on(protocol + "-r-" + request, handler);
219✔
286
    return this;
219✔
287
  }
288

289
  /**
290
   * Removes a registered request handler.
291
   *
292
   * @param protocol - name of the protocol to unregister the handler for
293
   * @param request - name of the request (also referred to as topic. See {@link Topics} for some known ones)
294
   * @param handler - registered request handler
295
   */
296
  public removeRequestHandler(protocol: string | Protocols, request: string | Topics, handler: GlobalRequestHandler): this {
297
    this.internalEventEmitter.removeListener(protocol + "-r-" + request, handler);
×
298
    return this;
×
299
  }
300

301
  public prepareSession(connection: HAPConnection, controllerKeySalt: Buffer, callback: PrepareSessionCallback): void {
302
    debug("Preparing for incoming HDS connection from %s", connection.sessionID);
×
303
    const accessoryKeySalt = crypto.randomBytes(32);
×
304
    const salt = Buffer.concat([controllerKeySalt, accessoryKeySalt]);
×
305

306
    const accessoryToControllerEncryptionKey = hapCrypto.HKDF(
×
307
      "sha512",
308
      salt,
309
      connection.encryption!.sharedSecret,
310
      DataStreamServer.accessoryToControllerInfo,
311
      32,
312
    );
313
    const controllerToAccessoryEncryptionKey = hapCrypto.HKDF(
×
314
      "sha512",
315
      salt,
316
      connection.encryption!.sharedSecret,
317
      DataStreamServer.controllerToAccessoryInfo,
318
      32,
319
    );
320

321
    const preparedSession: PreparedDataStreamSession = {
×
322
      connection: connection,
323
      accessoryToControllerEncryptionKey: accessoryToControllerEncryptionKey,
324
      controllerToAccessoryEncryptionKey: controllerToAccessoryEncryptionKey,
325
      accessoryKeySalt: accessoryKeySalt,
326
      connectTimeout: setTimeout(() => this.timeoutPreparedSession(preparedSession), 10000),
×
327
    };
328
    preparedSession.connectTimeout!.unref();
×
329
    this.preparedSessions.push(preparedSession);
×
330

331
    this.checkTCPServerEstablished(preparedSession, (error) => {
×
332
      if (error) {
×
333
        callback(error);
×
334
      } else {
335
        callback(undefined, preparedSession);
×
336
      }
337
    });
338
  }
339

340
  private timeoutPreparedSession(preparedSession: PreparedDataStreamSession) {
341
    debug("Prepared HDS session timed out out since no connection was opened for 10 seconds (%s)", preparedSession.connection.sessionID);
×
342
    const index = this.preparedSessions.indexOf(preparedSession);
×
343
    if (index >= 0) {
×
344
      this.preparedSessions.splice(index, 1);
×
345
    }
346

347
    this.checkCloseable();
×
348
  }
349

350
  private checkTCPServerEstablished(preparedSession: PreparedDataStreamSession, callback: (error?: Error) => void) {
351
    switch (this.state) {
×
352
    case ServerState.UNINITIALIZED:
353
      debug("Starting up TCP server.");
×
354
      this.tcpServer = net.createServer();
×
355

356
      this.tcpServer.once("listening", this.listening.bind(this, preparedSession, callback));
×
357
      this.tcpServer.on("connection", this.onConnection.bind(this));
×
358
      this.tcpServer.on("close", this.closed.bind(this));
×
359

360
      this.tcpServer.listen();
×
361
      this.state = ServerState.BINDING;
×
362
      break;
×
363
    case ServerState.BINDING:
364
      debug("TCP server already running. Waiting for it to bind.");
×
365
      this.tcpServer!.once("listening", this.listening.bind(this, preparedSession, callback));
×
366
      break;
×
367
    case ServerState.LISTENING:
368
      debug("Instructing client to connect to already running TCP server");
×
369
      preparedSession.port = this.tcpPort;
×
370
      callback();
×
371
      break;
×
372
    case ServerState.CLOSING:
373
      debug("TCP socket is currently closing. Trying again when server is fully closed and opening a new one then.");
×
374
      this.tcpServer!.once("close", () => setTimeout(() => this.checkTCPServerEstablished(preparedSession, callback), 10));
×
375
      break;
×
376
    }
377
  }
378

379
  private listening(preparedSession: PreparedDataStreamSession, callback: (error?: Error) => void) {
380
    this.state = ServerState.LISTENING;
×
381

382
    const address = this.tcpServer!.address();
×
383
    if (address && typeof address !== "string") { // address is only typeof string when listening to a pipe or unix socket
×
384
      this.tcpPort = address.port;
×
385
      preparedSession.port = address.port;
×
386

387
      debug("TCP server is now listening for new data stream connections on port %s", address.port);
×
388
      callback();
×
389
    }
390
  }
391

392
  private onConnection(socket: Socket) {
393
    debug("[%s] New DataStream connection was established", socket.remoteAddress);
×
394
    // eslint-disable-next-line @typescript-eslint/no-use-before-define
395
    const connection = new DataStreamConnection(socket);
×
396

397
    connection.on(DataStreamConnectionEvent.IDENTIFICATION, this.handleSessionIdentification.bind(this, connection));
×
398
    connection.on(DataStreamConnectionEvent.HANDLE_MESSAGE_GLOBALLY, this.handleMessageGlobally.bind(this, connection));
×
399
    connection.on(DataStreamConnectionEvent.CLOSED, this.connectionClosed.bind(this, connection));
×
400

401
    this.connections.push(connection);
×
402

403
    this.emit(DataStreamServerEvent.CONNECTION_OPENED, connection);
×
404
  }
405

406
  private handleSessionIdentification(connection: DataStreamConnection, firstFrame: HDSFrame, callback: IdentificationCallback) {
407
    let identifiedSession: PreparedDataStreamSession | undefined = undefined;
×
408
    for (let i = 0; i < this.preparedSessions.length; i++) {
×
409
      const preparedSession = this.preparedSessions[i];
×
410

411
      // if we successfully decrypt the first frame with this key we know to which session this connection belongs
412
      if (connection.decryptHDSFrame(firstFrame, preparedSession.controllerToAccessoryEncryptionKey)) {
×
413
        identifiedSession = preparedSession;
×
414
        break;
×
415
      }
416
    }
417

418
    callback(identifiedSession);
×
419

420
    if (identifiedSession) {
×
421
      debug("[%s] Connection was successfully identified (linked with sessionId: %s)", connection.remoteAddress, identifiedSession.connection.sessionID);
×
422
      const index = this.preparedSessions.indexOf(identifiedSession);
×
423
      if (index >= 0) {
×
424
        this.preparedSessions.splice(index, 1);
×
425
      }
426

427
      clearTimeout(identifiedSession.connectTimeout!);
×
428
      identifiedSession.connectTimeout = undefined;
×
429

430
      // we have currently no experience with data stream connections, maybe it would be good to index active connections
431
      // by their hap sessionId in order to clear out old but still open connections when the controller opens a new one
432
      // on the other hand the keepAlive should handle that also :thinking:
433
    } else { // we looped through all session and didn't find anything
434
      debug("[%s] Could not identify connection. Terminating.", connection.remoteAddress);
×
435
      connection.close(); // disconnecting since first message was not a valid hello
×
436
    }
437
  }
438

439
  private handleMessageGlobally(connection: DataStreamConnection, message: DataStreamMessage) {
440
    assert.notStrictEqual(message.type, MessageType.RESPONSE); // responses can't physically get here
×
441

442
    let separator = "";
×
443
    const args = [];
×
444
    if (message.type === MessageType.EVENT) {
×
445
      separator = "-e-";
×
446
    } else if (message.type === MessageType.REQUEST) {
×
447
      separator = "-r-";
×
448
      args.push(message.id!);
×
449
    }
450
    args.push(message.message);
×
451

452
    let hadListeners;
453
    try {
×
454
      hadListeners = this.internalEventEmitter.emit(message.protocol + separator + message.topic, connection, ...args);
×
455
    } catch (error) {
456
      hadListeners = true;
×
457
      debug("[%s] Error occurred while dispatching handler for HDS message: %o", connection.remoteAddress, message);
×
458
      debug(error.stack);
×
459
    }
460

461
    if (!hadListeners) {
×
462
      debug("[%s] WARNING no handler was found for message: %o", connection.remoteAddress, message);
×
463
    }
464
  }
465

466
  private connectionClosed(connection: DataStreamConnection) {
467
    debug("[%s] DataStream connection closed", connection.remoteAddress);
×
468

469
    this.connections.splice(this.connections.indexOf(connection), 1);
×
470
    this.emit(DataStreamServerEvent.CONNECTION_CLOSED, connection);
×
471

472
    this.checkCloseable();
×
473

474
    if (this.state === ServerState.CLOSING && this.removeListenersOnceClosed && this.connections.length === 0) {
×
475
      this.removeAllListeners(); // see this.destroy()
×
476
    }
477
  }
478

479
  private checkCloseable() {
480
    if (this.connections.length === 0 && this.preparedSessions.length === 0 && this.state < ServerState.CLOSING) {
×
481
      debug("Last connection disconnected. Closing the server now.");
×
482

483
      this.state = ServerState.CLOSING;
×
484
      this.tcpServer!.close();
×
485
    }
486
  }
487

488
  /**
489
   * This method will fully stop the DataStreamServer
490
   */
491
  public destroy(): void {
492
    if (this.state > ServerState.UNINITIALIZED && this.state < ServerState.CLOSING) {
6!
493
      this.tcpServer!.close();
×
494
      for (const connection of this.connections) {
×
495
        connection.close();
×
496
      }
497
    }
498

499
    this.state = ServerState.CLOSING;
6✔
500

501
    this.removeListenersOnceClosed = true;
6✔
502
    this.internalEventEmitter.removeAllListeners();
6✔
503
  }
504

505
  private closed() {
506
    this.tcpServer = undefined;
×
507
    this.tcpPort = undefined;
×
508

509
    this.state = ServerState.UNINITIALIZED;
×
510
  }
511

512
}
513

514
/**
515
 * @group HomeKit Data Streams (HDS)
516
 */
517
export type IdentificationCallback = (identifiedSession?: PreparedDataStreamSession) => void;
518

519
/**
520
 * @group HomeKit Data Streams (HDS)
521
 */
522
export const enum DataStreamConnectionEvent {
18✔
523
  /**
524
   * This event is emitted when the first HDSFrame is received from a new connection.
525
   * The connection expects the handler to identify the connection by trying to match the decryption keys.
526
   * If identification was successful the PreparedDataStreamSession should be supplied to the callback,
527
   * otherwise undefined should be supplied.
528
   */
529
  IDENTIFICATION = "identification",
18✔
530
  /**
531
   * This event is emitted when no handler could be found for the given protocol of an event or request message.
532
   */
533
  HANDLE_MESSAGE_GLOBALLY = "handle-message-globally",
18✔
534
  /**
535
   * This event is emitted when the socket of the connection was closed.
536
   */
537
  CLOSED = "closed",
18✔
538
}
539

540
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
541
export declare interface DataStreamConnection {
542
  on(event: "identification", listener: (frame: HDSFrame, callback: IdentificationCallback) => void): this;
543
  on(event: "handle-message-globally", listener: (message: DataStreamMessage) => void): this;
544
  on(event: "closed", listener: () => void): this;
545

546
  emit(event: "identification", frame: HDSFrame, callback: IdentificationCallback): boolean;
547
  emit(event: "handle-message-globally", message: DataStreamMessage): boolean;
548
  emit(event: "closed"): boolean;
549
}
550

551
/**
552
 * @group HomeKit Data Streams (HDS)
553
 */
554
export const enum HDSConnectionErrorType {
18✔
555
  ILLEGAL_STATE = 1,
18✔
556
  CLOSED_SOCKET = 2,
18✔
557
  MAX_PAYLOAD_LENGTH = 3,
18✔
558
}
559

560
/**
561
 * @group HomeKit Data Streams (HDS)
562
 */
563
export class HDSConnectionError extends Error {
18✔
564
  readonly type: HDSConnectionErrorType;
565

566
  constructor(message: string, type: HDSConnectionErrorType) {
567
    super(message);
×
568
    this.type = type;
×
569
  }
570
}
571

572
/**
573
 * DataStream connection which holds any necessary state information, encryption and decryption keys, manages
574
 * protocol handlers and also handles sending and receiving of data stream frames.
575
 *
576
 * @group HomeKit Data Streams (HDS)
577
 */
578
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
579
export class DataStreamConnection extends EventEmitter {
18✔
580

581
  private static readonly MAX_PAYLOAD_LENGTH = 0b11111111111111111111;
18✔
582

583
  private socket: Socket;
584
  private connection?: HAPConnection; // reference to the hap connection. is present when state > UNIDENTIFIED
585
  readonly remoteAddress: string;
586
  /*
587
        Since our DataStream server does only listen on one port and this port is supplied to every client
588
        which wants to connect, we do not really know which client is who when we receive a tcp connection.
589
        Thus, we find the correct PreparedDataStreamSession object by testing the encryption keys of all available
590
        prepared sessions. Then we can reference this hds connection with the correct hap connection and mark it as identified.
591
     */
592
  private state: ConnectionState = ConnectionState.UNIDENTIFIED;
×
593

594
  private accessoryToControllerEncryptionKey?: Buffer;
595
  private controllerToAccessoryEncryptionKey?: Buffer;
596

597
  private accessoryToControllerNonce: number;
598
  private readonly accessoryToControllerNonceBuffer: Buffer;
599
  private controllerToAccessoryNonce: number;
600
  private readonly controllerToAccessoryNonceBuffer: Buffer;
601

602
  private frameBuffer?: Buffer; // used to store incomplete HDS frames
603

604
  private readonly hapConnectionClosedListener: () => void;
605
  private protocolHandlers: Record<string, DataStreamProtocolHandler> = {}; // used to store protocolHandlers identified by their protocol name
×
606

607
  private responseHandlers: Record<number, ResponseHandler> = {}; // used to store responseHandlers indexed by their respective requestId
×
608
  private responseTimers: Record<number, NodeJS.Timeout> = {}; // used to store response timeouts indexed by their respective requestId
×
609

610
  private helloTimer?: NodeJS.Timeout;
611

612
  constructor(socket: Socket) {
613
    super();
×
614
    this.socket = socket;
×
615
    this.remoteAddress = socket.remoteAddress!;
×
616

617
    this.socket.setNoDelay(true); // disable Nagle algorithm
×
618
    this.socket.setKeepAlive(true);
×
619

620
    this.accessoryToControllerNonce = 0;
×
621
    this.accessoryToControllerNonceBuffer = Buffer.alloc(8);
×
622
    this.controllerToAccessoryNonce = 0;
×
623
    this.controllerToAccessoryNonceBuffer = Buffer.alloc(8);
×
624

625
    this.hapConnectionClosedListener = this.onHAPSessionClosed.bind(this);
×
626

627
    this.addProtocolHandler(Protocols.CONTROL, {
×
628
      requestHandler: {
629
        [Topics.HELLO]: this.handleHello.bind(this),
630
      },
631
    });
632

633
    this.helloTimer = setTimeout(() => {
×
634
      debug("[%s] Hello message did not arrive in time. Killing the connection", this.remoteAddress);
×
635
      this.close();
×
636
    }, 10000);
637

638
    this.socket.on("data", this.onSocketData.bind(this));
×
639
    this.socket.on("error", this.onSocketError.bind(this));
×
640
    this.socket.on("close", this.onSocketClose.bind(this));
×
641

642
    // this is to mitigate the event emitter "memory leak warning".
643
    // e.g. with HSV there might be multiple cameras subscribing to the CLOSE event. one subscription for
644
    // every active recording stream on a camera. The default limit of 10 might be easily reached.
645
    // Setting a high limit isn't the prefect solution, but will avoid false positives but ensures that
646
    // a warning is still be printed if running long enough.
647
    this.setMaxListeners(100);
×
648
  }
649

650
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
651
  private handleHello(id: number, message: Record<any, any>) {
652
    // that hello is indeed the _first_ message received is verified in onSocketData(...)
653
    debug("[%s] Received hello message from client: %o", this.remoteAddress, message);
×
654

655
    clearTimeout(this.helloTimer!);
×
656
    this.helloTimer = undefined;
×
657

658
    this.state = ConnectionState.READY;
×
659

660
    this.sendResponse(Protocols.CONTROL, Topics.HELLO, id);
×
661
  }
662

663
  /**
664
   * Registers a new protocol handler to handle incoming messages.
665
   * The same protocol cannot be registered multiple times.
666
   *
667
   * @param protocol - name of the protocol to register the handler for
668
   * @param protocolHandler - object to be registered as protocol handler
669
   */
670
  addProtocolHandler(protocol: string | Protocols, protocolHandler: DataStreamProtocolHandler): boolean {
671
    if (this.protocolHandlers[protocol] !== undefined) {
×
672
      return false;
×
673
    }
674

675
    this.protocolHandlers[protocol] = protocolHandler;
×
676
    return true;
×
677
  }
678

679
  /**
680
   * Removes a protocol handler if it is registered.
681
   *
682
   * @param protocol - name of the protocol to unregister the handler for
683
   * @param protocolHandler - object which will be unregistered
684
   */
685
  removeProtocolHandler(protocol: string | Protocols, protocolHandler: DataStreamProtocolHandler): void {
686
    const current = this.protocolHandlers[protocol];
×
687

688
    if (current === protocolHandler) {
×
689
      delete this.protocolHandlers[protocol];
×
690
    }
691
  }
692

693
  /**
694
   * Sends a new event message to the connected client.
695
   *
696
   * @param protocol - name of the protocol
697
   * @param event - name of the event (also referred to as topic. See {@link Topics} for some known ones)
698
   * @param message - message dictionary which gets sent along the event
699
   */
700
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
701
  sendEvent(protocol: string | Protocols, event: string | Topics, message: Record<any, any> = {}): void {
×
702
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
703
    const header: Record<any, any> = {};
×
704
    header.protocol = protocol;
×
705
    header.event = event;
×
706

707
    if (this.state === ConnectionState.READY) {
×
708
      this.sendHDSFrame(header, message);
×
709
    }
710
  }
711

712
  /**
713
   * Sends a new request message to the connected client.
714
   *
715
   * @param protocol - name of the protocol
716
   * @param request - name of the request (also referred to as topic. See {@link Topics} for some known ones)
717
   * @param message - message dictionary which gets sent along the request
718
   * @param callback - handler which gets supplied with an error object if the response didn't
719
   *                   arrive in time or the status and the message dictionary from the response
720
   */
721
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
722
  sendRequest(protocol: string | Protocols, request: string | Topics, message: Record<any, any> = {}, callback: ResponseHandler): void {
×
723
    let requestId: number;
724
    do { // generate unused requestId
×
725
      // currently writing int64 to data stream is not really supported, so 32-bit int will be the max
726
      requestId = Math.floor(Math.random() * 4294967295);
×
727
    } while (this.responseHandlers[requestId] !== undefined);
728

729
    this.responseHandlers[requestId] = callback;
×
730
    this.responseTimers[requestId] = setTimeout(() => {
×
731
      // we did not receive a response => close socket
732
      this.close();
×
733

734
      const handler = this.responseHandlers[requestId];
×
735

736
      delete this.responseHandlers[requestId];
×
737
      delete this.responseTimers[requestId];
×
738

739
      // handler should be able to clean up their stuff
740
      handler(new Error("timeout"), undefined, {});
×
741
    }, 10000); // 10s timer
742

743
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
744
    const header: Record<any, any> = {};
×
745
    header.protocol = protocol;
×
746
    header.request = request;
×
747
    header.id = new Int64(requestId);
×
748

749
    this.sendHDSFrame(header, message);
×
750
  }
751

752
  /**
753
   * Send a new response message to a received request message to the client.
754
   *
755
   * @param protocol - name of the protocol
756
   * @param response - name of the response (also referred to as topic. See {@link Topics} for some known ones)
757
   * @param id - id from the request, to associate the response to the request
758
   * @param status - status indication if the request was successful. A status of zero indicates success.
759
   * @param message - message dictionary which gets sent along the response
760
   */
761
  sendResponse(
762
    protocol: string | Protocols,
763
    response: string | Topics,
764
    id: number,
765
    status: HDSStatus = HDSStatus.SUCCESS,
×
766
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
767
    message: Record<any, any> = {},
×
768
  ): void {
769
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
770
    const header: Record<any, any> = {};
×
771
    header.protocol = protocol;
×
772
    header.response = response;
×
773
    header.id = new Int64(id);
×
774
    header.status = new Int64(status);
×
775

776
    this.sendHDSFrame(header, message);
×
777
  }
778

779
  private onSocketData(data: Buffer) {
780
    if (this.state >= ConnectionState.CLOSING) {
×
781
      return;
×
782
    }
783

784
    let frameIndex = 0;
×
785
    const frames: HDSFrame[] = this.decodeHDSFrames(data);
×
786
    if (frames.length === 0) { // not enough data
×
787
      return;
×
788
    }
789

790
    if (this.state === ConnectionState.UNIDENTIFIED) {
×
791
      // at the beginning we are only interested in trying to decrypt the first frame in order to test decryption keys
792
      const firstFrame = frames[frameIndex++];
×
793
      this.emit(DataStreamConnectionEvent.IDENTIFICATION, firstFrame, (identifiedSession?: PreparedDataStreamSession) => {
×
794
        if (identifiedSession) {
×
795
          // horray, we found our connection
796
          this.connection = identifiedSession.connection;
×
797
          this.accessoryToControllerEncryptionKey = identifiedSession.accessoryToControllerEncryptionKey;
×
798
          this.controllerToAccessoryEncryptionKey = identifiedSession.controllerToAccessoryEncryptionKey;
×
799
          this.state = ConnectionState.EXPECTING_HELLO;
×
800

801
          // below listener is removed in .close()
802
          this.connection.setMaxListeners(this.connection.getMaxListeners() + 1);
×
803
          this.connection.on(HAPConnectionEvent.CLOSED, this.hapConnectionClosedListener); // register close listener
×
804

805
          debug("[%s] Registering CLOSED handler to HAP connection. Connection currently has %d close handlers!",
×
806
            this.remoteAddress, this.connection.listeners(HAPConnectionEvent.CLOSED).length);
807
        }
808
      });
809

810
      if (this.state === ConnectionState.UNIDENTIFIED) {
×
811
        // did not find a prepared connection, server already closed this connection; nothing to do here
812
        return;
×
813
      }
814
    }
815

816
    for (; frameIndex < frames.length; frameIndex++) { // decrypt all remaining frames
×
817
      if (!this.decryptHDSFrame(frames[frameIndex])) {
×
818
        debug("[%s] HDS frame decryption or authentication failed. Connection will be terminated!", this.remoteAddress);
×
819
        this.close();
×
820
        return;
×
821
      }
822
    }
823

824
    const messages: DataStreamMessage[] = this.decodePayloads(frames); // decode contents of payload
×
825

826
    if (this.state === ConnectionState.EXPECTING_HELLO) {
×
827
      const firstMessage = messages[0];
×
828

829
      if (firstMessage.protocol !== Protocols.CONTROL || firstMessage.type !== MessageType.REQUEST || firstMessage.topic !== Topics.HELLO) {
×
830
        // first message is not the expected hello request
831
        debug("[%s] First message received was not the expected hello message. Instead got: %o", this.remoteAddress, firstMessage);
×
832
        this.close();
×
833
        return;
×
834
      }
835
    }
836

837
    messages.forEach(message => {
×
838
      if (message.type === MessageType.RESPONSE) {
×
839
        // protocol and topic are currently not tested here; just assumed they are correct;
840
        // probably they are as the requestId is unique per connection no matter what protocol is used
841
        const responseHandler = this.responseHandlers[message.id!];
×
842
        const responseTimer = this.responseTimers[message.id!];
×
843

844
        if (responseTimer) {
×
845
          clearTimeout(responseTimer);
×
846
          delete this.responseTimers[message.id!];
×
847
        }
848

849
        if (!responseHandler) {
×
850
          // we got a response to a request we did not send; we ignore it for now, since nobody will be hurt
851
          debug("WARNING we received a response to a request we have not sent: %o", message);
×
852
          return;
×
853
        }
854

855
        try {
×
856
          responseHandler(undefined, message.status!, message.message);
×
857
        } catch (error) {
858
          debug("[%s] Error occurred while dispatching response handler for HDS message: %o", this.remoteAddress, message);
×
859
          debug(error.stack);
×
860
        }
861
        delete this.responseHandlers[message.id!];
×
862
      } else {
863
        const handler = this.protocolHandlers[message.protocol];
×
864
        if (handler === undefined) {
×
865
          // send message to the server to check if there are some global handlers for it
866
          this.emit(DataStreamConnectionEvent.HANDLE_MESSAGE_GLOBALLY, message);
×
867
          return;
×
868
        }
869

870
        if (message.type === MessageType.EVENT) {
×
871
          let eventHandler: EventHandler;
872
          if (!handler.eventHandler || !(eventHandler = handler.eventHandler[message.topic])) {
×
873
            debug("[%s] WARNING no event handler was found for message: %o", this.remoteAddress, message);
×
874
            return;
×
875
          }
876

877
          try {
×
878
            eventHandler(message.message);
×
879
          } catch (error) {
880
            debug("[%s] Error occurred while dispatching event handler for HDS message: %o", this.remoteAddress, message);
×
881
            debug(error.stack);
×
882
          }
883
        } else if (message.type === MessageType.REQUEST) {
×
884
          let requestHandler: RequestHandler;
885
          if (!handler.requestHandler || !(requestHandler = handler.requestHandler[message.topic])) {
×
886
            debug("[%s] WARNING no request handler was found for message: %o", this.remoteAddress, message);
×
887
            return;
×
888
          }
889

890
          try {
×
891
            requestHandler(message.id!, message.message);
×
892
          } catch (error) {
893
            debug("[%s] Error occurred while dispatching request handler for HDS message: %o", this.remoteAddress, message);
×
894
            debug(error.stack);
×
895
          }
896
        } else {
897
          debug("[%s] Encountered unknown message type with id %d", this.remoteAddress, message.type);
×
898
        }
899
      }
900
    });
901
  }
902

903
  private decodeHDSFrames(data: Buffer) {
904
    if (this.frameBuffer !== undefined) {
×
905
      data = Buffer.concat([this.frameBuffer, data]);
×
906
      this.frameBuffer = undefined;
×
907
    }
908

909
    const totalBufferLength = data.length;
×
910
    const frames: HDSFrame[] = [];
×
911

912
    for (let frameBegin = 0; frameBegin < totalBufferLength;) {
×
913
      if (frameBegin + 4 > totalBufferLength) {
×
914
        // we don't have enough data in the buffer for the next header
915
        this.frameBuffer = data.subarray(frameBegin);
×
916
        break;
×
917
      }
918

919
      const payloadType = data.readUInt8(frameBegin); // type defining structure of payload; 8-bit; currently expected to be 1
×
920
      const payloadLength = data.readUIntBE(frameBegin + 1, 3); // read 24-bit big-endian uint length field
×
921

922
      if (payloadLength > DataStreamConnection.MAX_PAYLOAD_LENGTH) {
×
923
        debug("[%s] Connection send payload with size bigger than the maximum allow for data stream", this.remoteAddress);
×
924
        this.close();
×
925
        return [];
×
926
      }
927

928
      const remainingBufferLength = totalBufferLength - frameBegin - 4; // subtract 4 for payloadType (1-byte) and payloadLength (3-byte)
×
929
      // check if the data from this frame is already there (payload + 16-byte authTag)
930
      if (payloadLength + 16 > remainingBufferLength) {
×
931
        // Frame is fragmented, so we wait until we receive more
932
        this.frameBuffer = data.subarray(frameBegin);
×
933
        break;
×
934
      }
935

936
      const payloadBegin = frameBegin + 4;
×
937
      const authTagBegin = payloadBegin + payloadLength;
×
938

939
      const header = data.subarray(frameBegin, payloadBegin); // header is also authenticated using authTag
×
940
      const cipheredPayload = data.subarray(payloadBegin, authTagBegin);
×
941
      const plaintextPayload = Buffer.alloc(payloadLength);
×
942
      const authTag = data.subarray(authTagBegin, authTagBegin + 16);
×
943

944
      frameBegin = authTagBegin + 16; // move to next frame
×
945

946
      if (payloadType === 1) {
×
947
        const hdsFrame: HDSFrame = {
×
948
          header: header,
949
          cipheredPayload: cipheredPayload,
950
          authTag: authTag,
951
        };
952
        frames.push(hdsFrame);
×
953
      } else {
954
        debug("[%s] Encountered unknown payload type %d for payload: %s", this.remoteAddress, plaintextPayload.toString("hex"));
×
955
      }
956
    }
957

958
    return frames;
×
959
  }
960

961
  /**
962
   * @private file-private API
963
   */
964
  decryptHDSFrame(frame: HDSFrame, keyOverwrite?: Buffer): boolean {
965
    hapCrypto.writeUInt64LE(this.controllerToAccessoryNonce, this.controllerToAccessoryNonceBuffer, 0); // update nonce buffer
×
966

967
    const key = keyOverwrite || this.controllerToAccessoryEncryptionKey!;
×
968
    try {
×
969
      frame.plaintextPayload = hapCrypto.chacha20_poly1305_decryptAndVerify(key, this.controllerToAccessoryNonceBuffer,
×
970
        frame.header, frame.cipheredPayload, frame.authTag);
971
      this.controllerToAccessoryNonce++; // we had a successful encryption, increment the nonce
×
972
      return true;
×
973
    } catch (error) {
974
      // frame decryption or authentication failed. Could happen when our guess for a PreparedDataStreamSession is wrong
975
      return false;
×
976
    }
977
  }
978

979
  private decodePayloads(frames: HDSFrame[]) {
980
    const messages: DataStreamMessage[] = [];
×
981

982
    frames.forEach(frame => {
×
983
      const payload = frame.plaintextPayload;
×
984
      if (!payload) {
×
985
        throw new HDSConnectionError("Reached illegal state. Encountered HDSFrame with wasn't decrypted yet!", HDSConnectionErrorType.ILLEGAL_STATE);
×
986
      }
987

988
      const headerLength = payload.readUInt8(0);
×
989
      const messageLength = payload.length - headerLength - 1;
×
990

991
      const headerBegin = 1;
×
992
      const messageBegin = headerBegin + headerLength;
×
993

994
      const headerPayload = new DataStreamReader(payload.subarray(headerBegin, headerBegin + headerLength));
×
995
      const messagePayload = new DataStreamReader(payload.subarray(messageBegin, messageBegin + messageLength));
×
996

997
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
998
      let headerDictionary: Record<any, any>;
999
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
1000
      let messageDictionary: Record<any, any>;
1001
      try {
×
1002
        headerDictionary = DataStreamParser.decode(headerPayload);
×
1003
        headerPayload.finished();
×
1004
      } catch (error) {
1005
        debug("[%s] Failed to decode header payload: %s", this.remoteAddress, error.message);
×
1006
        return;
×
1007
      }
1008

1009
      try {
×
1010
        messageDictionary = DataStreamParser.decode(messagePayload);
×
1011
        messagePayload.finished();
×
1012
      } catch (error) {
1013
        debug("[%s] Failed to decode message payload: %s (header: %o)", this.remoteAddress, error.message, headerDictionary);
×
1014
        return;
×
1015
      }
1016

1017
      let type: MessageType;
1018
      const protocol: string = headerDictionary.protocol;
×
1019
      let topic: string;
1020
      let id: number | undefined = undefined;
×
1021
      let status: HDSStatus | undefined = undefined;
×
1022

1023
      if (headerDictionary.event !== undefined) {
×
1024
        type = MessageType.EVENT;
×
1025
        topic = headerDictionary.event;
×
1026
      } else if (headerDictionary.request !== undefined) {
×
1027
        type = MessageType.REQUEST;
×
1028
        topic = headerDictionary.request;
×
1029
        id = headerDictionary.id;
×
1030
      } else if (headerDictionary.response !== undefined) {
×
1031
        type = MessageType.RESPONSE;
×
1032
        topic = headerDictionary.response;
×
1033
        id = headerDictionary.id;
×
1034
        status = headerDictionary.status;
×
1035
      } else {
1036
        debug("[%s] Encountered unknown payload header format: %o (message: %o)", this.remoteAddress, headerDictionary, messageDictionary);
×
1037
        return;
×
1038
      }
1039

1040
      const message: DataStreamMessage = {
×
1041
        type: type,
1042
        protocol: protocol,
1043
        topic: topic,
1044
        id: id,
1045
        status: status,
1046
        message: messageDictionary,
1047
      };
1048
      messages.push(message);
×
1049
    });
1050

1051
    return messages;
×
1052
  }
1053

1054
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1055
  private sendHDSFrame(header: Record<any, any>, message: Record<any, any>) {
1056
    if (this.state >= ConnectionState.CLOSING) {
×
1057
      throw new HDSConnectionError("Cannot send message on closing/closed socket!", HDSConnectionErrorType.CLOSED_SOCKET);
×
1058
    }
1059

1060
    const headerWriter = new DataStreamWriter();
×
1061
    const messageWriter = new DataStreamWriter();
×
1062

1063
    DataStreamParser.encode(header, headerWriter);
×
1064
    DataStreamParser.encode(message, messageWriter);
×
1065

1066

1067
    const payloadHeaderBuffer = Buffer.alloc(1);
×
1068
    payloadHeaderBuffer.writeUInt8(headerWriter.length(), 0);
×
1069
    const payloadBuffer = Buffer.concat([payloadHeaderBuffer, headerWriter.getData(), messageWriter.getData()]);
×
1070
    if (payloadBuffer.length > DataStreamConnection.MAX_PAYLOAD_LENGTH) {
×
1071
      throw new HDSConnectionError(
×
1072
        "Tried sending payload with length larger than the maximum allowed for data stream",
1073
        HDSConnectionErrorType.MAX_PAYLOAD_LENGTH,
1074
      );
1075
    }
1076

1077
    const frameTypeBuffer = Buffer.alloc(1);
×
1078
    frameTypeBuffer.writeUInt8(1, 0);
×
1079
    let frameLengthBuffer = Buffer.alloc(4);
×
1080
    frameLengthBuffer.writeUInt32BE(payloadBuffer.length, 0);
×
1081
    frameLengthBuffer = frameLengthBuffer.subarray(1, 4); // a bit hacky but the only real way to write 24-bit int in node
×
1082

1083
    const frameHeader = Buffer.concat([frameTypeBuffer, frameLengthBuffer]);
×
1084

1085
    hapCrypto.writeUInt64LE(this.accessoryToControllerNonce++, this.accessoryToControllerNonceBuffer);
×
1086
    const encrypted = hapCrypto.chacha20_poly1305_encryptAndSeal(
×
1087
      this.accessoryToControllerEncryptionKey!,
1088
      this.accessoryToControllerNonceBuffer,
1089
      frameHeader,
1090
      payloadBuffer,
1091
    );
1092

1093
    this.socket.write(Buffer.concat([frameHeader, encrypted.ciphertext, encrypted.authTag]));
×
1094

1095
    /* Useful for debugging outgoing packages and detecting encoding errors
1096
        console.log("SENT DATA: " + payloadBuffer.toString("hex"));
1097
        const frame: HDSFrame = {
1098
            header: frameHeader,
1099
            plaintextPayload: payloadBuffer,
1100
            cipheredPayload: cipheredPayload,
1101
            authTag: authTag,
1102
        };
1103
        const sentMessage = this.decodePayloads([frame])[0];
1104
        console.log("Sent message: " + JSON.stringify(sentMessage, null, 4));
1105
        //*/
1106
  }
1107

1108
  close(): void { // closing socket by sending FIN packet; incoming data will be ignored from that point on
1109
    if (this.state >= ConnectionState.CLOSING) {
×
1110
      return; // connection is already closing/closed
×
1111
    }
1112

1113
    this.state = ConnectionState.CLOSING;
×
1114
    this.socket.end();
×
1115
  }
1116

1117
  isConsideredClosed(): boolean {
1118
    return this.state >= ConnectionState.CLOSING;
×
1119
  }
1120

1121
  private onHAPSessionClosed() {
1122
    // If the hap connection is closed it is probably also a good idea to close the data stream connection
1123
    debug("[%s] HAP connection disconnected. Also closing DataStream connection now.", this.remoteAddress);
×
1124
    this.close();
×
1125
  }
1126

1127
  private onSocketError(error: Error) {
1128
    debug("[%s] Encountered socket error: %s", this.remoteAddress, error.message);
×
1129
    // onSocketClose will be called next
1130
  }
1131

1132
  private onSocketClose() {
1133
    // this instance is now considered completely dead
1134
    this.state = ConnectionState.CLOSED;
×
1135
    this.emit(DataStreamConnectionEvent.CLOSED);
×
1136

1137
    this.connection?.removeListener(HAPConnectionEvent.CLOSED, this.hapConnectionClosedListener);
×
1138
    this.connection?.setMaxListeners(this.connection.getMaxListeners() - 1);
×
1139
    this.removeAllListeners();
×
1140
  }
1141

1142
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc