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

homebridge / HAP-NodeJS / 6675472827

28 Oct 2023 07:01AM UTC coverage: 64.993% (-0.02%) from 65.014%
6675472827

push

github

donavanbecker
update workflow to .github's branch latest

1860 of 3646 branches covered (0.0%)

Branch coverage included in aggregate %.

7395 of 10594 relevant lines covered (69.8%)

201.17 hits per line

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

82.35
/src/lib/util/eventedhttp.ts
1
import { getNetAddress } from "@homebridge/ciao/lib/util/domain-formatter";
28✔
2
import assert from "assert";
28✔
3
import createDebug from "debug";
28✔
4
import { EventEmitter } from "events";
28✔
5
import { SrpServer } from "fast-srp-hap";
6
import http, { IncomingMessage, ServerResponse } from "http";
28✔
7
import net, { AddressInfo, Socket } from "net";
28✔
8
import os from "os";
28✔
9
import { CharacteristicEventNotification, EventNotification } from "../../internal-types";
10
import { CharacteristicValue, Nullable, SessionIdentifier } from "../../types";
11
import * as hapCrypto from "./hapCrypto";
28✔
12
import { getOSLoopbackAddressIfAvailable } from "./net-utils";
28✔
13
import * as uuid from "./uuid";
28✔
14

15

16
const debug = createDebug("HAP-NodeJS:EventedHTTPServer");
28✔
17
const debugCon = createDebug("HAP-NodeJS:EventedHTTPServer:Connection");
28✔
18
const debugEvents = createDebug("HAP-NodeJS:EventEmitter");
28✔
19

20
/**
21
 * @group HAP Accessory Server
22
 */
23
export type HAPUsername = string;
24
/**
25
 * @group HAP Accessory Server
26
 */
27
export type EventName = string; // "<aid>.<iid>"
28

29
/**
30
 * Simple struct to hold vars needed to support HAP encryption.
31
 *
32
 * @group Cryptography
33
 */
34
export class HAPEncryption {
28✔
35

36
  readonly clientPublicKey: Buffer;
37
  readonly secretKey: Buffer;
38
  readonly publicKey: Buffer;
39
  readonly sharedSecret: Buffer;
40
  readonly hkdfPairEncryptionKey: Buffer;
41

42
  accessoryToControllerCount = 0;
66✔
43
  controllerToAccessoryCount = 0;
66✔
44
  accessoryToControllerKey: Buffer;
45
  controllerToAccessoryKey: Buffer;
46

47
  incompleteFrame?: Buffer;
48

49
  public constructor(clientPublicKey: Buffer, secretKey: Buffer, publicKey: Buffer, sharedSecret: Buffer, hkdfPairEncryptionKey: Buffer) {
50
    this.clientPublicKey = clientPublicKey;
66✔
51
    this.secretKey = secretKey;
66✔
52
    this.publicKey = publicKey;
66✔
53
    this.sharedSecret = sharedSecret;
66✔
54
    this.hkdfPairEncryptionKey = hkdfPairEncryptionKey;
66✔
55

56
    this.accessoryToControllerKey = Buffer.alloc(0);
66✔
57
    this.controllerToAccessoryKey = Buffer.alloc(0);
66✔
58
  }
59
}
28✔
60

61
/**
62
 * @group HAP Accessory Server
63
 */
64
export const enum EventedHTTPServerEvent {
28✔
65
  LISTENING = "listening",
28✔
66
  CONNECTION_OPENED = "connection-opened",
28✔
67
  REQUEST = "request",
28✔
68
  CONNECTION_CLOSED = "connection-closed",
28✔
69
}
70

71
/**
72
 * @group HAP Accessory Server
73
 */
74
export declare interface EventedHTTPServer {
75

76
  on(event: "listening", listener: (port: number, address: string) => void): this;
77
  on(event: "connection-opened", listener: (connection: HAPConnection) => void): this;
78
  on(event: "request", listener: (connection: HAPConnection, request: IncomingMessage, response: ServerResponse) => void): this;
79
  on(event: "connection-closed", listener: (connection: HAPConnection) => void): this;
80

81
  emit(event: "listening", port: number, address: string): boolean;
82
  emit(event: "connection-opened", connection: HAPConnection): boolean;
83
  emit(event: "request", connection: HAPConnection, request: IncomingMessage, response: ServerResponse): boolean;
84
  emit(event: "connection-closed", connection: HAPConnection): boolean;
85

86
}
87

88
/**
89
 * EventedHTTPServer provides an HTTP-like server that supports HAP "extensions" for security and events.
90
 *
91
 * Implementation
92
 * --------------
93
 * In order to implement the "custom HTTP" server required by the HAP protocol (see HAPServer.js) without completely
94
 * reinventing the wheel, we create both a generic TCP socket server and a standard Node HTTP server.
95
 * The TCP socket server acts as a proxy, allowing users of this class to transform data (for encryption) as necessary
96
 * and passing through bytes directly to the HTTP server for processing. This way we get Node to do all
97
 * the "heavy lifting" of HTTP like parsing headers and formatting responses.
98
 *
99
 * Events are sent by simply waiting for current HTTP traffic to subside and then sending a custom response packet
100
 * directly down the wire via the socket.
101
 *
102
 * Each connection to the main TCP server gets its own internal HTTP server, so we can track ongoing requests/responses
103
 * for safe event insertion.
104
 *
105
 * @group HAP Accessory Server
106
 */
107
export class EventedHTTPServer extends EventEmitter {
28✔
108

109
  private static readonly CONNECTION_TIMEOUT_LIMIT = 16; // if we have more (or equal) # connections we start the timeout
28✔
110
  private static readonly MAX_CONNECTION_IDLE_TIME = 60 * 60 * 1000; // 1h
28✔
111

112
  private readonly tcpServer: net.Server;
113

114
  /**
115
   * Set of all currently connected HAP connections.
116
   */
117
  private readonly connections: Set<HAPConnection> = new Set();
148✔
118
  /**
119
   * Session dictionary indexed by username/identifier. The username uniquely identifies every person added to the home.
120
   * So there can be multiple sessions open for a single username (multiple devices connected to the same Apple ID).
121
   */
122
  private readonly connectionsByUsername: Map<HAPUsername, HAPConnection[]> = new Map();
148✔
123
  private connectionIdleTimeout?: NodeJS.Timeout;
124
  private connectionLoggingInterval?: NodeJS.Timeout;
125

126
  constructor() {
127
    super();
148✔
128
    this.tcpServer = net.createServer();
148✔
129
  }
130

131
  private scheduleNextConnectionIdleTimeout(): void {
28✔
132
    this.connectionIdleTimeout = undefined;
×
133

134
    if (!this.tcpServer.listening) {
×
135
      return;
×
136
    }
137

138
    debug("Running idle timeout timer...");
×
139

140
    const currentTime = new Date().getTime();
×
141
    let nextTimeout = -1;
×
142

143
    for (const connection of this.connections) {
×
144
      const timeDelta = currentTime - connection.lastSocketOperation;
×
145

146
      if (timeDelta >= EventedHTTPServer.MAX_CONNECTION_IDLE_TIME) {
×
147
        debug("[%s] Closing connection as it was inactive for " + timeDelta + "ms");
×
148
        connection.close();
×
149
      } else {
150
        nextTimeout = Math.max(nextTimeout, EventedHTTPServer.MAX_CONNECTION_IDLE_TIME - timeDelta);
×
151
      }
152
    }
153

154
    if (this.connections.size >= EventedHTTPServer.CONNECTION_TIMEOUT_LIMIT) {
×
155
      this.connectionIdleTimeout = setTimeout(this.scheduleNextConnectionIdleTimeout.bind(this), nextTimeout);
×
156
    }
157
  }
158

159
  public address(): AddressInfo {
28✔
160
    return this.tcpServer.address() as AddressInfo;
8✔
161
  }
162

163
  public listen(targetPort: number, hostname?: string): void {
148✔
164
    this.tcpServer.listen(targetPort, hostname, () => {
148✔
165
      const address = this.tcpServer.address() as AddressInfo; // address() is only a string when listening to unix domain sockets
88✔
166

167
      debug("Server listening on %s:%s", address.family === "IPv6"? `[${address.address}]`: address.address, address.port);
88!
168

169
      this.connectionLoggingInterval = setInterval(() => {
88✔
170
        const connectionInformation = [...this.connections]
×
171
          .map(connection => `${connection.remoteAddress}:${connection.remotePort}`)
×
172
          .join(", ");
173
        debug("Currently %d hap connections open: %s", this.connections.size, connectionInformation);
×
174
      }, 60_000);
175
      this.connectionLoggingInterval.unref();
88✔
176

177
      this.emit(EventedHTTPServerEvent.LISTENING, address.port, address.address);
88✔
178
    });
179

180
    this.tcpServer.on("connection", this.onConnection.bind(this));
148✔
181
  }
182

183
  public stop(): void {
28✔
184
    if (this.connectionLoggingInterval != null) {
214✔
185
      clearInterval(this.connectionLoggingInterval);
88✔
186
      this.connectionLoggingInterval = undefined;
88✔
187
    }
188

189
    if (this.connectionIdleTimeout != null) {
214!
190
      clearTimeout(this.connectionIdleTimeout);
×
191
      this.connectionIdleTimeout = undefined;
×
192
    }
193

194
    this.tcpServer.close();
214✔
195
    for (const connection of this.connections) {
214✔
196
      connection.close();
124✔
197
    }
198
  }
199

200
  public destroy(): void {
28✔
201
    this.stop();
6✔
202
    this.removeAllListeners();
6✔
203
  }
204

205
  /**
206
   * Send an event notification for given characteristic and changed value to all connected clients.
207
   * If `originator` is specified, the given {@link HAPConnection} will be excluded from the broadcast.
208
   *
209
   * @param aid - The accessory id of the updated characteristic.
210
   * @param iid - The instance id of the updated characteristic.
211
   * @param value - The newly set value of the characteristic.
212
   * @param originator - If specified, the connection will not get an event message.
213
   * @param immediateDelivery - The HAP spec requires some characteristics to be delivery immediately.
214
   *   Namely, for the {@link Characteristic.ButtonEvent} and the {@link Characteristic.ProgrammableSwitchEvent} characteristics.
215
   */
216
  public broadcastEvent(aid: number, iid: number, value: Nullable<CharacteristicValue>, originator?: HAPConnection, immediateDelivery?: boolean): void {
28✔
217
    for (const connection of this.connections) {
24✔
218
      if (connection === originator) {
4✔
219
        debug("[%s] Muting event '%s' notification for this connection since it originated here.", connection.remoteAddress, aid + "." + iid);
2✔
220
        continue;
2✔
221
      }
222

223
      connection.sendEvent(aid, iid, value, immediateDelivery);
2✔
224
    }
225
  }
226

227
  private onConnection(socket: Socket): void {
68✔
228
    // eslint-disable-next-line @typescript-eslint/no-use-before-define
229
    const connection = new HAPConnection(this, socket);
68✔
230

231
    connection.on(HAPConnectionEvent.REQUEST, (request, response) => {
68✔
232
      this.emit(EventedHTTPServerEvent.REQUEST, connection, request, response);
132✔
233
    });
234
    connection.on(HAPConnectionEvent.AUTHENTICATED, this.handleConnectionAuthenticated.bind(this, connection));
68✔
235
    connection.on(HAPConnectionEvent.CLOSED, this.handleConnectionClose.bind(this, connection));
68✔
236

237
    this.connections.add(connection);
68✔
238

239
    debug("[%s] New connection from client on interface %s (%s)", connection.remoteAddress, connection.networkInterface, connection.localAddress);
68✔
240

241
    this.emit(EventedHTTPServerEvent.CONNECTION_OPENED, connection);
68✔
242

243
    if (this.connections.size >= EventedHTTPServer.CONNECTION_TIMEOUT_LIMIT && !this.connectionIdleTimeout) {
68!
244
      this.scheduleNextConnectionIdleTimeout();
×
245
    }
246
  }
247

248
  private handleConnectionAuthenticated(connection: HAPConnection, username: HAPUsername): void {
28✔
249
    const connections: HAPConnection[] | undefined = this.connectionsByUsername.get(username);
30✔
250
    if (!connections) {
30✔
251
      this.connectionsByUsername.set(username, [connection]);
28✔
252
    } else if (!connections.includes(connection)) { // ensure this doesn't get added more than one time
2✔
253
      connections.push(connection);
2✔
254
    }
255
  }
256

257
  private handleConnectionClose(connection: HAPConnection): void {
28✔
258
    this.emit(EventedHTTPServerEvent.CONNECTION_CLOSED, connection);
64✔
259

260
    this.connections.delete(connection);
64✔
261

262
    if (connection.username) { // aka connection was authenticated
64✔
263
      const connections = this.connectionsByUsername.get(connection.username);
30✔
264
      if (connections) {
30✔
265
        const index = connections.indexOf(connection);
30✔
266
        if (index !== -1) {
30✔
267
          connections.splice(index, 1);
30✔
268
        }
269

270
        if (connections.length === 0) {
30✔
271
          this.connectionsByUsername.delete(connection.username);
28✔
272
        }
273
      }
274
    }
275
  }
276

277
  /**
278
   * This method is to be called when a given {@link HAPConnection} performs a request that should result in the disconnection
279
   * of all other {@link HAPConnection} with the same {@link HAPUsername}.
280
   *
281
   * The initiator MUST be in the middle of a http request were the response was not served yet.
282
   * Otherwise, the initiator connection might reside in a state where it isn't disconnected and can't make any further requests.
283
   *
284
   * @param initiator - The connection that requested to disconnect all connections of the same username.
285
   * @param username - The username for which all connections shall be closed.
286
   */
287
  public static destroyExistingConnectionsAfterUnpair(initiator: HAPConnection, username: HAPUsername): void {
28✔
288
    const connections: HAPConnection[] | undefined = initiator.server.connectionsByUsername.get(username);
2✔
289

290
    if (connections) {
2✔
291
      for (const connection of connections) {
4✔
292
        connection.closeConnectionAsOfUnpair(initiator);
4✔
293
      }
294
    }
295
  }
296

297
}
28✔
298

299
/**
300
 * @private
301
 * @group HAP Accessory Server
302
 */
303
export const enum HAPConnectionState {
28✔
304
  CONNECTING, // initial state, setup is going on
28✔
305
  FULLY_SET_UP, // internal http server is running and connection is established
28✔
306
  AUTHENTICATED, // encryption is set up
28✔
307
  // above signals represent an alive connection
308

309
  // below states are considered "closed or soon closed"
310
  TO_BE_TEARED_DOWN, // when in this state, connection should be closed down after response was sent out
28✔
311
  CLOSING, // close was called
28✔
312
  CLOSED, // dead
28✔
313
}
314

315
/**
316
 * @group HAP Accessory Server
317
 */
318
export const enum HAPConnectionEvent {
28✔
319
  REQUEST = "request",
28✔
320
  AUTHENTICATED = "authenticated",
28✔
321
  CLOSED = "closed",
28✔
322
}
323

324
/**
325
 * @group HAP Accessory Server
326
 */
327
export declare interface HAPConnection {
328
  on(event: "request", listener: (request: IncomingMessage, response: ServerResponse) => void): this;
329
  on(event: "authenticated", listener: (username: HAPUsername) => void): this;
330
  on(event: "closed", listener: () => void): this;
331

332
  emit(event: "request", request: IncomingMessage, response: ServerResponse): boolean;
333
  emit(event: "authenticated", username: HAPUsername): boolean;
334
  emit(event: "closed"): boolean;
335
}
336

337
/**
338
 * Manages a single iOS-initiated HTTP connection during its lifetime.
339
 * @group HAP Accessory Server
340
 */
341
export class HAPConnection extends EventEmitter {
28✔
342
  /**
343
   * @private file-private API
344
   */
345
  readonly server: EventedHTTPServer;
346

347
  readonly sessionID: SessionIdentifier; // uuid unique to every HAP connection
348
  private state: HAPConnectionState = HAPConnectionState.CONNECTING;
68✔
349
  readonly localAddress: string;
350
  readonly remoteAddress: string; // cache because it becomes undefined in 'onClientSocketClose'
351
  readonly remotePort: number;
352
  readonly networkInterface: string;
353

354
  private readonly tcpSocket: Socket;
355
  private readonly internalHttpServer: http.Server;
356
  private httpSocket?: Socket; // set when in state FULLY_SET_UP
357
  private internalHttpServerPort?: number;
358
  private internalHttpServerAddress?: string;
359

360
  lastSocketOperation: number = new Date().getTime();
68✔
361

362
  private pendingClientSocketData?: Buffer = Buffer.alloc(0); // data received from client before HTTP proxy is fully setup
68✔
363
  private handlingRequest = false; // true while we are composing an HTTP response (so events can wait)
68✔
364

365
  username?: HAPUsername; // username is unique to every user in the home, basically identifies an Apple ID
366
  encryption?: HAPEncryption; // created in handlePairVerifyStepOne
367
  srpServer?: SrpServer;
368
  _pairSetupState?: number; // TODO ensure those two states are always correctly reset?
369
  _pairVerifyState?: number;
370

371
  private registeredEvents: Set<EventName> = new Set();
68✔
372
  private eventsTimer?: NodeJS.Timeout;
373
  private readonly queuedEvents: CharacteristicEventNotification[] = [];
68✔
374
  /**
375
   * If true, the above {@link queuedEvents} contains events which are set to be delivered immediately!
376
   */
377
  private eventsQueuedForImmediateDelivery = false;
68✔
378

379
  timedWritePid?: number;
380
  timedWriteTimeout?: NodeJS.Timeout;
381

382
  constructor(server: EventedHTTPServer, clientSocket: Socket) {
383
    super();
68✔
384

385
    this.server = server;
68✔
386
    this.sessionID = uuid.generate(clientSocket.remoteAddress + ":" + clientSocket.remotePort);
68✔
387
    this.localAddress = clientSocket.localAddress;
68✔
388
    this.remoteAddress = clientSocket.remoteAddress!; // cache because it becomes undefined in 'onClientSocketClose'
68✔
389
    this.remotePort = clientSocket.remotePort!;
68✔
390
    this.networkInterface = HAPConnection.getLocalNetworkInterface(clientSocket);
68✔
391

392
    // clientSocket is the socket connected to the actual iOS device
393
    this.tcpSocket = clientSocket;
68✔
394
    this.tcpSocket.on("data", this.onTCPSocketData.bind(this));
68✔
395
    this.tcpSocket.on("close", this.onTCPSocketClose.bind(this));
68✔
396
    // we MUST register for this event, otherwise the error will bubble up to the top and crash the node process entirely.
397
    this.tcpSocket.on("error", this.onTCPSocketError.bind(this));
68✔
398
    this.tcpSocket.setNoDelay(true); // disable Nagle algorithm
68✔
399
    // "HAP accessory servers must not use keepalive messages, which periodically wake up iOS devices".
400
    // Thus, we don't configure any tcp keepalive
401

402
    // create our internal HTTP server for this connection that we will proxy data to and from
403
    this.internalHttpServer = http.createServer();
68✔
404
    this.internalHttpServer.timeout = 0; // clients expect to hold connections open as long as they want
68✔
405
    this.internalHttpServer.keepAliveTimeout = 0; // workaround for https://github.com/nodejs/node/issues/13391
68✔
406
    this.internalHttpServer.on("listening", this.onHttpServerListening.bind(this));
68✔
407
    this.internalHttpServer.on("request", this.handleHttpServerRequest.bind(this));
68✔
408
    this.internalHttpServer.on("error", this.onHttpServerError.bind(this));
68✔
409
    // close event is added later on the "connect" event as possible listen retries would throw unnecessary close events
410
    this.internalHttpServer.listen(0, this.internalHttpServerAddress = getOSLoopbackAddressIfAvailable());
68✔
411
  }
412

413
  private debugListenerRegistration(event: string | symbol, registration = true, beforeCount = -1): void {
426✔
414
    const stackTrace = new Error().stack!.split("\n")[3];
216✔
415
    const eventCount = this.listeners(event).length;
216✔
416

417
    const tabs1 = event === HAPConnectionEvent.AUTHENTICATED ? "\t" : "\t\t";
216✔
418
    const tabs2 = !registration ? "\t" : "\t\t";
216✔
419

420
    // eslint-disable-next-line max-len
421
    debugEvents(`[${this.remoteAddress}] ${registration ? "Registered" : "Unregistered"} event '${String(event).toUpperCase()}' ${tabs1}(total: ${eventCount}${!registration ? " Before: " + beforeCount : ""}) ${tabs2}${stackTrace}`);
216✔
422
  }
423

424
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
425
  on(event: string | symbol, listener: (...args: any[]) => void): this {
28✔
426
    const result =  super.on(event, listener);
210✔
427
    this.debugListenerRegistration(event);
210✔
428
    return result;
210✔
429
  }
430

431
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
432
  addListener(event: string | symbol, listener: (...args: any[]) => void): this {
28✔
433
    const result = super.addListener(event, listener);
×
434
    this.debugListenerRegistration(event);
×
435
    return result;
×
436
  }
437

438
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
439
  removeListener(event: string | symbol, listener: (...args: any[]) => void): this {
28✔
440
    const beforeCount = this.listeners(event).length;
6✔
441
    const result = super.removeListener(event, listener);
6✔
442
    this.debugListenerRegistration(event, false, beforeCount);
6✔
443
    return result;
6✔
444
  }
445

446
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
447
  off(event: string | symbol, listener: (...args: any[]) => void): this {
28✔
448
    const result =  super.off(event, listener);
×
449
    const beforeCount = this.listeners(event).length;
×
450
    this.debugListenerRegistration(event, false, beforeCount);
×
451
    return result;
×
452
  }
453

454
  /**
455
   * This method is called once the connection has gone through pair-verify.
456
   * As any HomeKit controller will initiate a pair-verify after the pair-setup procedure, this method gets
457
   * not called on the initial pair-setup.
458
   *
459
   * Once this method has been called, the connection is authenticated and encryption is turned on.
460
   */
461
  public connectionAuthenticated(username: HAPUsername): void {
28✔
462
    this.state = HAPConnectionState.AUTHENTICATED;
30✔
463
    this.username = username;
30✔
464

465
    this.emit(HAPConnectionEvent.AUTHENTICATED, username);
30✔
466
  }
467

468
  public isAuthenticated(): boolean {
28✔
469
    return this.state === HAPConnectionState.AUTHENTICATED;
52✔
470
  }
471

472
  public close(): void {
28✔
473
    if (this.state >= HAPConnectionState.CLOSING) {
256✔
474
      return; // already closed/closing
190✔
475
    }
476

477
    this.state = HAPConnectionState.CLOSING;
66✔
478
    this.tcpSocket.destroy();
66✔
479
  }
480

481
  public closeConnectionAsOfUnpair(initiator: HAPConnection): void {
28✔
482
    if (this === initiator) {
4✔
483
      // the initiator of the unpair request is this connection, meaning it unpaired itself.
484
      // we still need to send the response packet to the unpair request.
485
      this.state = HAPConnectionState.TO_BE_TEARED_DOWN;
2✔
486
    } else {
487
      // as HomeKit requires it, destroy any active session which got unpaired
488
      this.close();
2✔
489
    }
490
  }
491

492
  public sendEvent(aid: number, iid: number, value: Nullable<CharacteristicValue>, immediateDelivery?: boolean): void {
28✔
493
    assert(aid != null, "HAPConnection.sendEvent: aid must be defined!");
24✔
494
    assert(iid != null, "HAPConnection.sendEvent: iid must be defined!");
24✔
495

496
    const eventName = aid + "." + iid;
24✔
497

498
    if (!this.registeredEvents.has(eventName)) {
24✔
499
      // non verified connections can't register events, so this case is covered!
500
      return;
2✔
501
    }
502

503
    const event: CharacteristicEventNotification = {
22✔
504
      aid: aid,
505
      iid: iid,
506
      value: value,
507
    };
508

509
    if (immediateDelivery) {
22✔
510
      // some characteristics are required to deliver notifications immediately
511
      // we will flush all other events too, on that occasion.
512
      this.queuedEvents.push(event);
4✔
513
      this.eventsQueuedForImmediateDelivery = true;
4✔
514

515
      if (this.eventsTimer) {
4✔
516
        clearTimeout(this.eventsTimer);
2✔
517
        this.eventsTimer = undefined;
2✔
518
      }
519
      this.handleEventsTimeout();
4✔
520
      return;
4✔
521
    }
522

523
    // we search the list of queued events in reverse order.
524
    // if the last element with the same aid and iid has the same value we don't want to send the event notification twice.
525
    // BUT, we do not want to override previous event notifications which have a different value. Automations must be executed!
526
    for (let i = this.queuedEvents.length - 1; i >= 0; i--) {
18✔
527
      const queuedEvent = this.queuedEvents[i];
10✔
528
      if (queuedEvent.aid === aid && queuedEvent.iid === iid) {
10✔
529
        if (queuedEvent.value === value) {
10✔
530
          return; // the same event was already queued. do not add it again!
2✔
531
        }
532

533
        break; // we break in any case
8✔
534
      }
535
    }
536

537
    this.queuedEvents.push(event);
16✔
538

539
    // if there is already a timer running we just add it in the queue.
540
    if (!this.eventsTimer) {
16✔
541
      this.eventsTimer = setTimeout(this.handleEventsTimeout.bind(this), 250);
10✔
542
      this.eventsTimer.unref();
10✔
543
    }
544
  }
545

546
  private handleEventsTimeout(): void {
28✔
547
    this.eventsTimer = undefined;
10✔
548
    if (this.state > HAPConnectionState.AUTHENTICATED) {
10!
549
      // connection is closed or about to be closed. no need to send any further events
550
      return;
×
551
    }
552

553
    this.writeQueuedEventNotifications();
10✔
554
  }
555

556
  private writeQueuedEventNotifications(): void {
28✔
557
    if (this.queuedEvents.length === 0 || this.handlingRequest) {
138✔
558
      return; // don't send empty event notifications or if we are currently handling a request
128✔
559
    }
560

561
    if (this.eventsTimer) {
10✔
562
      // this method might be called when we have enqueued data AND data that is queued for immediate delivery!
563
      clearTimeout(this.eventsTimer);
2✔
564
      this.eventsTimer = undefined;
2✔
565
    }
566

567
    const eventData: EventNotification = {
10✔
568
      characteristics: [],
569
    };
570

571
    for (const queuedEvent of this.queuedEvents) {
20✔
572
      if (!this.registeredEvents.has(queuedEvent.aid + "." + queuedEvent.iid)) {
20!
573
        continue; // client unregistered that event in the meantime
×
574
      }
575

576
      eventData.characteristics.push(queuedEvent);
20✔
577
    }
578

579
    this.queuedEvents.splice(0, this.queuedEvents.length);
10✔
580
    this.eventsQueuedForImmediateDelivery = false;
10✔
581

582
    this.writeEventNotification(eventData);
10✔
583
  }
584

585
  /**
586
   * This will create an EVENT/1.0 notification header with the provided event notification.
587
   * If currently an HTTP request is in progress the assembled packet will be
588
   * added to the pending events list.
589
   *
590
   * @param notification - The event which should be sent out
591
   */
592
  private writeEventNotification(notification: EventNotification): void {
28✔
593
    debugCon("[%s] Sending HAP event notifications %o", this.remoteAddress, notification.characteristics);
10✔
594
    assert(!this.handlingRequest, "Can't write event notifications while handling a request!");
10✔
595

596
    // Apple backend processes events in reverse order, so we need to reverse the array
597
    // so that events are processed in chronological order.
598
    notification.characteristics.reverse();
10✔
599

600
    const dataBuffer = Buffer.from(JSON.stringify(notification), "utf8");
10✔
601
    const header = Buffer.from(
10✔
602
      "EVENT/1.0 200 OK\r\n" +
603
      "Content-Type: application/hap+json\r\n" +
604
      "Content-Length: " + dataBuffer.length + "\r\n" +
605
      "\r\n",
606
      "utf8", // buffer encoding
607
    );
608

609
    const buffer = Buffer.concat([header, dataBuffer]);
10✔
610
    this.tcpSocket.write(this.encrypt(buffer), this.handleTCPSocketWriteFulfilled.bind(this));
10✔
611
  }
612

613
  public enableEventNotifications(aid: number, iid: number): void {
28✔
614
    this.registeredEvents.add(aid + "." + iid);
2✔
615
  }
616

617
  public disableEventNotifications(aid: number, iid: number): void {
28✔
618
    this.registeredEvents.delete(aid + "." + iid);
×
619
  }
620

621
  public hasEventNotifications(aid: number, iid: number): boolean {
28✔
622
    return this.registeredEvents.has(aid + "." + iid);
4✔
623
  }
624

625
  public getRegisteredEvents(): Set<EventName> {
28✔
626
    return this.registeredEvents;
2✔
627
  }
628

629
  public clearRegisteredEvents(): void {
28✔
630
    this.registeredEvents.clear();
×
631
  }
632

633
  private encrypt(data: Buffer): Buffer {
28✔
634
    // if accessoryToControllerKey is not empty, then encryption is enabled for this connection. However, we'll
635
    // need to be careful to ensure that we don't encrypt the last few bytes of the response from handlePairVerifyStepTwo.
636
    // Since all communication calls are asynchronous, we could easily receive this 'encrypt' event for those bytes.
637
    // So we want to make sure that we aren't encrypting data until we have *received* some encrypted data from the client first.
638
    if (this.encryption && this.encryption.accessoryToControllerKey.length > 0 && this.encryption.controllerToAccessoryCount > 0) {
142✔
639
      return hapCrypto.layerEncrypt(data, this.encryption);
26✔
640
    }
641
    return data; // otherwise, we don't encrypt and return plaintext
116✔
642
  }
643

644
  private decrypt(data: Buffer): Buffer {
28✔
645
    if (this.encryption && this.encryption.controllerToAccessoryKey.length > 0) {
132✔
646
      // below call may throw an error if decryption failed
647
      return hapCrypto.layerDecrypt(data, this.encryption);
26✔
648
    }
649
    return data; // otherwise, we don't decrypt and return plaintext
106✔
650
  }
651

652
  private onHttpServerListening() {
68✔
653
    const addressInfo = this.internalHttpServer.address() as AddressInfo; // address() is only a string when listening to unix domain sockets
68✔
654
    const addressString = addressInfo.family === "IPv6"? `[${addressInfo.address}]`: addressInfo.address;
68!
655
    this.internalHttpServerPort = addressInfo.port;
68✔
656

657
    debugCon("[%s] Internal HTTP server listening on %s:%s", this.remoteAddress, addressString, addressInfo.port);
68✔
658

659
    this.internalHttpServer.on("close", this.onHttpServerClose.bind(this));
68✔
660

661
    // now we can establish a connection to this running HTTP server for proxying data
662
    this.httpSocket = net.createConnection(this.internalHttpServerPort, this.internalHttpServerAddress); // previously we used addressInfo.address
68✔
663
    this.httpSocket.setNoDelay(true); // disable Nagle algorithm
68✔
664

665
    this.httpSocket.on("data", this.handleHttpServerResponse.bind(this));
68✔
666
    // we MUST register for this event, otherwise the error will bubble up to the top and crash the node process entirely.
667
    this.httpSocket.on("error", this.onHttpSocketError.bind(this));
68✔
668
    this.httpSocket.on("close", this.onHttpSocketClose.bind(this));
68✔
669
    this.httpSocket.on("connect", () => {
68✔
670
      // we are now fully set up:
671
      //  - clientSocket is connected to the iOS device
672
      //  - serverSocket is connected to the httpServer
673
      //  - ready to proxy data!
674
      this.state = HAPConnectionState.FULLY_SET_UP;
68✔
675
      debugCon("[%s] Internal HTTP socket connected. HAPConnection now fully set up!", this.remoteAddress);
68✔
676

677
      // start by flushing any pending buffered data received from the client while we were setting up
678
      if (this.pendingClientSocketData && this.pendingClientSocketData.length > 0) {
68✔
679
        this.httpSocket!.write(this.pendingClientSocketData);
68✔
680
      }
681
      this.pendingClientSocketData = undefined;
68✔
682
    });
683
  }
684

685
  /**
686
   * This event handler is called when we receive data from a HomeKit controller on our tcp socket.
687
   * We store the data if the internal http server is not read yet, or forward it to the http server.
688
   */
689
  private onTCPSocketData(data: Buffer): void {
28✔
690
    if (this.state > HAPConnectionState.AUTHENTICATED) {
132!
691
      // don't accept data of a connection which is about to be closed or already closed
692
      return;
×
693
    }
694

695
    this.handlingRequest = true; // reverted to false once response was sent out
132✔
696
    this.lastSocketOperation = new Date().getTime();
132✔
697

698
    try {
132✔
699
      data = this.decrypt(data);
132✔
700
    } catch (error) { // decryption and/or verification failed, disconnect the client
701
      debugCon("[%s] Error occurred trying to decrypt incoming packet: %s", this.remoteAddress, error.message);
×
702
      this.close();
×
703
      return;
×
704
    }
705

706
    if (this.state < HAPConnectionState.FULLY_SET_UP) { // we're not setup yet, so add this data to our intermediate buffer
132✔
707
      this.pendingClientSocketData = Buffer.concat([this.pendingClientSocketData!, data]);
68✔
708
    } else {
709
      this.httpSocket!.write(data); // proxy it along to the HTTP server
64✔
710
    }
711
  }
712

713
  /**
714
   * This event handler is called when the internal http server receives a request.
715
   * Meaning we received data from the HomeKit controller in {@link onTCPSocketData}, which then send the
716
   * data unencrypted to the internal http server. And now it landed here, fully parsed as a http request.
717
   */
718
  private handleHttpServerRequest(request: IncomingMessage, response: ServerResponse): void {
28✔
719
    if (this.state > HAPConnectionState.AUTHENTICATED) {
132!
720
      // don't accept data of a connection which is about to be closed or already closed
721
      return;
×
722
    }
723

724
    debugCon("[%s] HTTP request: %s", this.remoteAddress, request.url);
132✔
725

726
    request.socket.setNoDelay(true);
132✔
727

728
    this.emit(HAPConnectionEvent.REQUEST, request, response);
132✔
729
  }
730

731
  /**
732
   * This event handler is called by the socket which is connected to our internal http server.
733
   * It is called with the response returned from the http server.
734
   * In this method we have to encrypt and forward the message back to the HomeKit controller.
735
   */
736
  private handleHttpServerResponse(data: Buffer): void {
132✔
737
    data = this.encrypt(data);
132✔
738
    this.tcpSocket.write(data, this.handleTCPSocketWriteFulfilled.bind(this));
132✔
739

740
    debugCon("[%s] HTTP Response is finished", this.remoteAddress);
132✔
741
    this.handlingRequest = false;
132✔
742

743
    if (this.state === HAPConnectionState.TO_BE_TEARED_DOWN) {
132✔
744
      setTimeout(() => this.close(), 10);
2✔
745
    } else if (this.state < HAPConnectionState.TO_BE_TEARED_DOWN) {
130✔
746
      if (!this.eventsTimer || this.eventsQueuedForImmediateDelivery) {
130✔
747
        // we deliver events if there is no eventsTimer (meaning it ran out in the meantime)
748
        // or when the queue contains events set to be delivered immediately
749
        this.writeQueuedEventNotifications();
128✔
750
      }
751
    }
752
  }
753

754
  private handleTCPSocketWriteFulfilled(): void {
28✔
755
    this.lastSocketOperation = new Date().getTime();
142✔
756
  }
757

758
  private onTCPSocketError(err: Error): void {
28✔
759
    debugCon("[%s] Client connection error: %s", this.remoteAddress, err.message);
×
760
    // onTCPSocketClose will be called next
761
  }
762

763
  private onTCPSocketClose(): void {
28✔
764
    this.state = HAPConnectionState.CLOSED;
64✔
765

766
    debugCon("[%s] Client connection closed", this.remoteAddress);
64✔
767

768
    if (this.httpSocket) {
64✔
769
      this.httpSocket.destroy();
64✔
770
    }
771
    this.internalHttpServer.close();
64✔
772

773
    this.emit(HAPConnectionEvent.CLOSED); // sending final closed event
64✔
774
    this.removeAllListeners(); // cleanup listeners, we are officially dead now
64✔
775
  }
776

777
  private onHttpServerError(err: Error & { code?: string }): void {
28✔
778
    debugCon("[%s] HTTP server error: %s", this.remoteAddress, err.message);
×
779
    if (err.code === "EADDRINUSE") {
×
780
      this.internalHttpServerPort = undefined;
×
781

782
      this.internalHttpServer.close();
×
783
      this.internalHttpServer.listen(0, this.internalHttpServerAddress = getOSLoopbackAddressIfAvailable());
×
784
    }
785
  }
786

787
  private onHttpServerClose(): void {
28✔
788
    debugCon("[%s] HTTP server was closed", this.remoteAddress);
128✔
789
    // make sure the iOS side is closed as well
790
    this.close();
128✔
791
  }
792

793
  private onHttpSocketError(err: Error): void {
28✔
794
    debugCon("[%s] HTTP connection error: ", this.remoteAddress, err.message);
×
795
    // onHttpSocketClose will be called next
796
  }
797

798
  private onHttpSocketClose(): void {
28✔
799
    debugCon("[%s] HTTP connection was closed", this.remoteAddress);
64✔
800
    // we only support a single long-lived connection to our internal HTTP server. Since it's closed,
801
    // we'll need to shut it down entirely.
802
    this.internalHttpServer.close();
64✔
803
  }
804

805
  public getLocalAddress(ipVersion: "ipv4" | "ipv6"): string {
28✔
806
    const infos = os.networkInterfaces()[this.networkInterface];
4✔
807

808
    if (ipVersion === "ipv4") {
4✔
809
      for (const info of infos) {
2✔
810
        // @ts-expect-error Nodejs 18+ uses the number 4 the string "IPv4"
811
        if (info.family === "IPv4" || info.family === 4) {
2!
812
          return info.address;
2✔
813
        }
814
      }
815

816
      throw new Error("Could not find " + ipVersion + " address for interface " + this.networkInterface);
×
817
    } else {
818
      let localUniqueAddress: string | undefined = undefined;
2✔
819

820
      for (const info of infos) {
4✔
821
        // @ts-expect-error Nodejs 18+ uses the number 6 instead of the string "IPv6"
822
        if (info.family === "IPv6" || info.family === 6) {
4✔
823
          if (!info.scopeid) {
2!
824
            return info.address;
2✔
825
          } else if (!localUniqueAddress) {
×
826
            localUniqueAddress = info.address;
×
827
          }
828
        }
829
      }
830

831
      if (!localUniqueAddress) {
×
832
        throw new Error("Could not find " + ipVersion + " address for interface " + this.networkInterface);
×
833
      }
834
      return localUniqueAddress;
×
835
    }
836
  }
837

838
  private static getLocalNetworkInterface(socket: Socket): string {
28✔
839
    let localAddress = socket.localAddress;
68✔
840

841
    if (localAddress.startsWith("::ffff:")) { // IPv4-Mapped IPv6 Address https://tools.ietf.org/html/rfc4291#section-2.5.5.2
68!
842
      localAddress = localAddress.substring(7);
×
843
    } else {
844
      const index = localAddress.indexOf("%");
68✔
845
      if (index !== -1) { // link-local ipv6
68!
846
        localAddress = localAddress.substring(0, index);
×
847
      }
848
    }
849

850
    const interfaces = os.networkInterfaces();
68✔
851
    for (const [name, infos] of Object.entries(interfaces)) {
68✔
852
      for (const info of infos) {
136✔
853
        if (info.address === localAddress) {
136✔
854
          return name;
68✔
855
        }
856
      }
857
    }
858

859
    // we couldn't map the address from above, we try now to match subnets (see https://github.com/homebridge/HAP-NodeJS/issues/847)
860
    const family = net.isIPv4(localAddress)? "IPv4": "IPv6";
×
861
    for (const [name, infos] of Object.entries(interfaces)) {
×
862
      for (const info of infos) {
×
863
        if (info.family !== family) {
×
864
          continue;
×
865
        }
866

867
        // check if the localAddress is in the same subnet
868
        if (getNetAddress(localAddress, info.netmask) === getNetAddress(info.address, info.netmask)) {
×
869
          return name;
×
870
        }
871
      }
872
    }
873

874
    console.log(`WARNING couldn't map socket coming from remote address ${socket.remoteAddress}:${socket.remotePort} \
×
875
    at local address ${socket.localAddress} to a interface!`);
876

877
    return Object.keys(interfaces)[1]; // just use the first interface after the loopback interface as fallback
×
878
  }
879

880
}
28✔
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

© 2025 Coveralls, Inc