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

homebridge / HAP-NodeJS / 9656191928

25 Jun 2024 03:56AM UTC coverage: 64.766% (-0.09%) from 64.855%
9656191928

Pull #1045

github

web-flow
Merge 4b14eb7d3 into a99b99fdf
Pull Request #1045: v1.0.0

1853 of 3661 branches covered (50.61%)

Branch coverage included in aggregate %.

31 of 51 new or added lines in 6 files covered. (60.78%)

188 existing lines in 3 files now uncovered.

7277 of 10436 relevant lines covered (69.73%)

315.33 hits per line

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

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

15

16
const debug = createDebug("HAP-NodeJS:EventedHTTPServer");
42✔
17
const debugCon = createDebug("HAP-NodeJS:EventedHTTPServer:Connection");
42✔
18
const debugEvents = createDebug("HAP-NodeJS:EventEmitter");
42✔
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 {
42✔
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;
99✔
43
  controllerToAccessoryCount = 0;
99✔
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;
99✔
51
    this.secretKey = secretKey;
99✔
52
    this.publicKey = publicKey;
99✔
53
    this.sharedSecret = sharedSecret;
99✔
54
    this.hkdfPairEncryptionKey = hkdfPairEncryptionKey;
99✔
55

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

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

71
/**
72
 * @group HAP Accessory Server
73
 */
74
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
75
export declare interface EventedHTTPServer {
76

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

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

87
}
88

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

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

114
  private readonly tcpServer: net.Server;
115

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

128
  constructor() {
129
    super();
252✔
130
    this.tcpServer = net.createServer();
252✔
131
  }
132

133
  private scheduleNextConnectionIdleTimeout(): void {
42✔
134
    this.connectionIdleTimeout = undefined;
×
135

136
    if (!this.tcpServer.listening) {
×
137
      return;
×
138
    }
139

140
    debug("Running idle timeout timer...");
×
141

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

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

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

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

161
  public address(): AddressInfo {
42✔
162
    return this.tcpServer.address() as AddressInfo;
12✔
163
  }
164

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

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

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

179
      this.emit(EventedHTTPServerEvent.LISTENING, address.port, address.address);
162✔
180
    });
181

182
    this.tcpServer.on("connection", this.onConnection.bind(this));
252✔
183
  }
184

185
  public stop(): void {
42✔
186
    if (this.connectionLoggingInterval != null) {
351✔
187
      clearInterval(this.connectionLoggingInterval);
162✔
188
      this.connectionLoggingInterval = undefined;
162✔
189
    }
190

191
    if (this.connectionIdleTimeout != null) {
351!
192
      clearTimeout(this.connectionIdleTimeout);
×
193
      this.connectionIdleTimeout = undefined;
×
194
    }
195

196
    this.tcpServer.close();
351✔
197
    for (const connection of this.connections) {
351✔
198
      connection.close();
186✔
199
    }
200
  }
201

202
  public destroy(): void {
42✔
203
    this.stop();
9✔
204
    this.removeAllListeners();
9✔
205
  }
206

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

225
      connection.sendEvent(aid, iid, value, immediateDelivery);
3✔
226
    }
227
  }
228

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

233
    connection.on(HAPConnectionEvent.REQUEST, (request, response) => {
102✔
234
      this.emit(EventedHTTPServerEvent.REQUEST, connection, request, response);
198✔
235
    });
236
    connection.on(HAPConnectionEvent.AUTHENTICATED, this.handleConnectionAuthenticated.bind(this, connection));
102✔
237
    connection.on(HAPConnectionEvent.CLOSED, this.handleConnectionClose.bind(this, connection));
102✔
238

239
    this.connections.add(connection);
102✔
240

241
    debug("[%s] New connection from client on interface %s (%s)", connection.remoteAddress, connection.networkInterface, connection.localAddress);
102✔
242

243
    this.emit(EventedHTTPServerEvent.CONNECTION_OPENED, connection);
102✔
244

245
    if (this.connections.size >= EventedHTTPServer.CONNECTION_TIMEOUT_LIMIT && !this.connectionIdleTimeout) {
102!
246
      this.scheduleNextConnectionIdleTimeout();
×
247
    }
248
  }
249

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

259
  private handleConnectionClose(connection: HAPConnection): void {
42✔
260
    this.emit(EventedHTTPServerEvent.CONNECTION_CLOSED, connection);
96✔
261

262
    this.connections.delete(connection);
96✔
263

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

272
        if (connections.length === 0) {
45✔
273
          this.connectionsByUsername.delete(connection.username);
42✔
274
        }
275
      }
276
    }
277
  }
278

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

292
    if (connections) {
3✔
293
      for (const connection of connections) {
6✔
294
        connection.closeConnectionAsOfUnpair(initiator);
6✔
295
      }
296
    }
297
  }
298

299
}
42✔
300

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

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

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

326
/**
327
 * @group HAP Accessory Server
328
 */
329
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
330
export declare interface HAPConnection {
331
  on(event: "request", listener: (request: IncomingMessage, response: ServerResponse) => void): this;
332
  on(event: "authenticated", listener: (username: HAPUsername) => void): this;
333
  on(event: "closed", listener: () => void): this;
334

335
  emit(event: "request", request: IncomingMessage, response: ServerResponse): boolean;
336
  emit(event: "authenticated", username: HAPUsername): boolean;
337
  emit(event: "closed"): boolean;
338
}
339

340
/**
341
 * Manages a single iOS-initiated HTTP connection during its lifetime.
342
 * @group HAP Accessory Server
343
 */
344
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
345
export class HAPConnection extends EventEmitter {
42✔
346
  /**
347
   * @private file-private API
348
   */
349
  readonly server: EventedHTTPServer;
350

351
  readonly sessionID: SessionIdentifier; // uuid unique to every HAP connection
352
  private state: HAPConnectionState = HAPConnectionState.CONNECTING;
102✔
353
  readonly localAddress: string;
354
  readonly remoteAddress: string; // cache because it becomes undefined in 'onClientSocketClose'
355
  readonly remotePort: number;
356
  readonly networkInterface: string;
357

358
  private readonly tcpSocket: Socket;
359
  private readonly internalHttpServer: http.Server;
360
  private httpSocket?: Socket; // set when in state FULLY_SET_UP
361
  private internalHttpServerPort?: number;
362
  private internalHttpServerAddress?: string;
363

364
  lastSocketOperation: number = new Date().getTime();
102✔
365

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

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

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

383
  timedWritePid?: number;
384
  timedWriteTimeout?: NodeJS.Timeout;
385

386
  constructor(server: EventedHTTPServer, clientSocket: Socket) {
387
    super();
102✔
388

389
    this.server = server;
102✔
390
    this.sessionID = uuid.generate(clientSocket.remoteAddress + ":" + clientSocket.remotePort);
102✔
391
    this.localAddress = clientSocket.localAddress as string;
102✔
392
    this.remoteAddress = clientSocket.remoteAddress!; // cache because it becomes undefined in 'onClientSocketClose'
102✔
393
    this.remotePort = clientSocket.remotePort!;
102✔
394
    this.networkInterface = HAPConnection.getLocalNetworkInterface(clientSocket);
102✔
395

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

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

417
  private debugListenerRegistration(event: string | symbol, registration = true, beforeCount = -1): void {
639✔
418
    const stackTrace = new Error().stack!.split("\n")[3];
324✔
419
    const eventCount = this.listeners(event).length;
324✔
420

421
    const tabs1 = event === HAPConnectionEvent.AUTHENTICATED ? "\t" : "\t\t";
324✔
422
    const tabs2 = !registration ? "\t" : "\t\t";
324✔
423

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

428
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
429
  on(event: string | symbol, listener: (...args: any[]) => void): this {
42✔
430
    const result =  super.on(event, listener);
315✔
431
    this.debugListenerRegistration(event);
315✔
432
    return result;
315✔
433
  }
434

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

442
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
443
  removeListener(event: string | symbol, listener: (...args: any[]) => void): this {
42✔
444
    const beforeCount = this.listeners(event).length;
9✔
445
    const result = super.removeListener(event, listener);
9✔
446
    this.debugListenerRegistration(event, false, beforeCount);
9✔
447
    return result;
9✔
448
  }
449

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

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

469
    this.emit(HAPConnectionEvent.AUTHENTICATED, username);
45✔
470
  }
471

472
  public isAuthenticated(): boolean {
42✔
473
    return this.state === HAPConnectionState.AUTHENTICATED;
78✔
474
  }
475

476
  public close(): void {
42✔
477
    if (this.state >= HAPConnectionState.CLOSING) {
384✔
478
      return; // already closed/closing
285✔
479
    }
480

481
    this.state = HAPConnectionState.CLOSING;
99✔
482
    this.tcpSocket.destroy();
99✔
483
  }
484

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

496
  public sendEvent(aid: number, iid: number, value: Nullable<CharacteristicValue>, immediateDelivery?: boolean): void {
42✔
497
    assert(aid != null, "HAPConnection.sendEvent: aid must be defined!");
36✔
498
    assert(iid != null, "HAPConnection.sendEvent: iid must be defined!");
36✔
499

500
    const eventName = aid + "." + iid;
36✔
501

502
    if (!this.registeredEvents.has(eventName)) {
36✔
503
      // non verified connections can't register events, so this case is covered!
504
      return;
3✔
505
    }
506

507
    const event: CharacteristicEventNotification = {
33✔
508
      aid: aid,
509
      iid: iid,
510
      value: value,
511
    };
512

513
    if (immediateDelivery) {
33✔
514
      // some characteristics are required to deliver notifications immediately
515
      // we will flush all other events too, on that occasion.
516
      this.queuedEvents.push(event);
6✔
517
      this.eventsQueuedForImmediateDelivery = true;
6✔
518

519
      if (this.eventsTimer) {
6✔
520
        clearTimeout(this.eventsTimer);
3✔
521
        this.eventsTimer = undefined;
3✔
522
      }
523
      this.handleEventsTimeout();
6✔
524
      return;
6✔
525
    }
526

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

537
        break; // we break in any case
12✔
538
      }
539
    }
540

541
    this.queuedEvents.push(event);
24✔
542

543
    // if there is already a timer running we just add it in the queue.
544
    if (!this.eventsTimer) {
24✔
545
      this.eventsTimer = setTimeout(this.handleEventsTimeout.bind(this), 250);
15✔
546
      this.eventsTimer.unref();
15✔
547
    }
548
  }
549

550
  private handleEventsTimeout(): void {
42✔
551
    this.eventsTimer = undefined;
15✔
552
    if (this.state > HAPConnectionState.AUTHENTICATED) {
15!
553
      // connection is closed or about to be closed. no need to send any further events
554
      return;
×
555
    }
556

557
    this.writeQueuedEventNotifications();
15✔
558
  }
559

560
  private writeQueuedEventNotifications(): void {
42✔
561
    if (this.queuedEvents.length === 0 || this.handlingRequest) {
207✔
562
      return; // don't send empty event notifications or if we are currently handling a request
192✔
563
    }
564

565
    if (this.eventsTimer) {
15✔
566
      // this method might be called when we have enqueued data AND data that is queued for immediate delivery!
567
      clearTimeout(this.eventsTimer);
3✔
568
      this.eventsTimer = undefined;
3✔
569
    }
570

571
    const eventData: EventNotification = {
15✔
572
      characteristics: [],
573
    };
574

575
    for (const queuedEvent of this.queuedEvents) {
30✔
576
      if (!this.registeredEvents.has(queuedEvent.aid + "." + queuedEvent.iid)) {
30!
577
        continue; // client unregistered that event in the meantime
×
578
      }
579

580
      eventData.characteristics.push(queuedEvent);
30✔
581
    }
582

583
    this.queuedEvents.splice(0, this.queuedEvents.length);
15✔
584
    this.eventsQueuedForImmediateDelivery = false;
15✔
585

586
    this.writeEventNotification(eventData);
15✔
587
  }
588

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

600
    // Apple backend processes events in reverse order, so we need to reverse the array
601
    // so that events are processed in chronological order.
602
    notification.characteristics.reverse();
15✔
603

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

613
    const buffer = Buffer.concat([header, dataBuffer]);
15✔
614
    this.tcpSocket.write(this.encrypt(buffer), this.handleTCPSocketWriteFulfilled.bind(this));
15✔
615
  }
616

617
  public enableEventNotifications(aid: number, iid: number): void {
42✔
618
    this.registeredEvents.add(aid + "." + iid);
3✔
619
  }
620

621
  public disableEventNotifications(aid: number, iid: number): void {
42✔
622
    this.registeredEvents.delete(aid + "." + iid);
×
623
  }
624

625
  public hasEventNotifications(aid: number, iid: number): boolean {
42✔
626
    return this.registeredEvents.has(aid + "." + iid);
6✔
627
  }
628

629
  public getRegisteredEvents(): Set<EventName> {
42✔
630
    return this.registeredEvents;
3✔
631
  }
632

633
  public clearRegisteredEvents(): void {
42✔
634
    this.registeredEvents.clear();
×
635
  }
636

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

648
  private decrypt(data: Buffer): Buffer {
42✔
649
    if (this.encryption && this.encryption.controllerToAccessoryKey.length > 0) {
198✔
650
      // below call may throw an error if decryption failed
651
      return hapCrypto.layerDecrypt(data, this.encryption);
39✔
652
    }
653
    return data; // otherwise, we don't decrypt and return plaintext
159✔
654
  }
655

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

661
    debugCon("[%s] Internal HTTP server listening on %s:%s", this.remoteAddress, addressString, addressInfo.port);
102✔
662

663
    this.internalHttpServer.on("close", this.onHttpServerClose.bind(this));
102✔
664

665
    // now we can establish a connection to this running HTTP server for proxying data
666
    this.httpSocket = net.createConnection(this.internalHttpServerPort, this.internalHttpServerAddress); // previously we used addressInfo.address
102✔
667
    this.httpSocket.setNoDelay(true); // disable Nagle algorithm
102✔
668

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

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

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

699
    this.handlingRequest = true; // reverted to false once response was sent out
198✔
700
    this.lastSocketOperation = new Date().getTime();
198✔
701

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

710
    if (this.state < HAPConnectionState.FULLY_SET_UP) { // we're not setup yet, so add this data to our intermediate buffer
198✔
711
      this.pendingClientSocketData = Buffer.concat([this.pendingClientSocketData!, data]);
102✔
712
    } else {
713
      this.httpSocket!.write(data); // proxy it along to the HTTP server
96✔
714
    }
715
  }
716

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

728
    debugCon("[%s] HTTP request: %s", this.remoteAddress, request.url);
198✔
729

730
    request.socket.setNoDelay(true);
198✔
731

732
    this.emit(HAPConnectionEvent.REQUEST, request, response);
198✔
733
  }
734

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

744
    debugCon("[%s] HTTP Response is finished", this.remoteAddress);
198✔
745
    this.handlingRequest = false;
198✔
746

747
    if (this.state === HAPConnectionState.TO_BE_TEARED_DOWN) {
198✔
748
      setTimeout(() => this.close(), 10);
3✔
749
    } else if (this.state < HAPConnectionState.TO_BE_TEARED_DOWN) {
195✔
750
      if (!this.eventsTimer || this.eventsQueuedForImmediateDelivery) {
195✔
751
        // we deliver events if there is no eventsTimer (meaning it ran out in the meantime)
752
        // or when the queue contains events set to be delivered immediately
753
        this.writeQueuedEventNotifications();
192✔
754
      }
755
    }
756
  }
757

758
  private handleTCPSocketWriteFulfilled(): void {
42✔
759
    this.lastSocketOperation = new Date().getTime();
213✔
760
  }
761

762
  private onTCPSocketError(err: Error): void {
42✔
763
    debugCon("[%s] Client connection error: %s", this.remoteAddress, err.message);
×
764
    // onTCPSocketClose will be called next
765
  }
766

767
  private onTCPSocketClose(): void {
42✔
768
    this.state = HAPConnectionState.CLOSED;
96✔
769

770
    debugCon("[%s] Client connection closed", this.remoteAddress);
96✔
771

772
    if (this.httpSocket) {
96✔
773
      this.httpSocket.destroy();
96✔
774
    }
775
    this.internalHttpServer.close();
96✔
776

777
    this.emit(HAPConnectionEvent.CLOSED); // sending final closed event
96✔
778
    this.removeAllListeners(); // cleanup listeners, we are officially dead now
96✔
779
  }
780

781
  private onHttpServerError(err: Error & { code?: string }): void {
42✔
782
    debugCon("[%s] HTTP server error: %s", this.remoteAddress, err.message);
×
783
    if (err.code === "EADDRINUSE") {
×
784
      this.internalHttpServerPort = undefined;
×
785

786
      this.internalHttpServer.close();
×
787
      this.internalHttpServer.listen(0, this.internalHttpServerAddress = getOSLoopbackAddressIfAvailable());
×
788
    }
789
  }
790

791
  private onHttpServerClose(): void {
42✔
792
    debugCon("[%s] HTTP server was closed", this.remoteAddress);
192✔
793
    // make sure the iOS side is closed as well
794
    this.close();
192✔
795
  }
796

797
  private onHttpSocketError(err: Error): void {
42✔
798
    debugCon("[%s] HTTP connection error: ", this.remoteAddress, err.message);
×
799
    // onHttpSocketClose will be called next
800
  }
801

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

809
  public getLocalAddress(ipVersion: "ipv4" | "ipv6"): string {
42✔
810
    const infos = os.networkInterfaces()[this.networkInterface] ?? [];
6!
811

812
    if (ipVersion === "ipv4") {
6✔
813
      if (infos) {
3✔
814
        for (const info of infos) {
3✔
815
          // @ts-expect-error Nodejs 18+ uses the number 4 the string "IPv4"
816
          if (info.family === "IPv4" || info.family === 4) {
3!
817
            return info.address;
3✔
818
          }
819
        }
820
      }
821

822
      throw new Error("Could not find " + ipVersion + " address for interface " + this.networkInterface);
×
823
    } else {
824
      let localUniqueAddress: string | undefined = undefined;
3✔
825

826
      if (infos) {
3✔
827
        for (const info of infos) {
6✔
828
          // @ts-expect-error Nodejs 18+ uses the number 6 instead of the string "IPv6"
829
          if (info.family === "IPv6" || info.family === 6) {
6✔
830
            if (!info.scopeid) {
3!
831
              return info.address;
3✔
832
            } else if (!localUniqueAddress) {
×
833
              localUniqueAddress = info.address;
×
834
            }
835
          }
836
        }
837
      }
838

839
      if (!localUniqueAddress) {
×
840
        throw new Error("Could not find " + ipVersion + " address for interface " + this.networkInterface);
×
841
      }
842
      return localUniqueAddress;
×
843
    }
844
  }
845

846
  private static getLocalNetworkInterface(socket: Socket): string {
42✔
847
    let localAddress = socket.localAddress as string;
102✔
848

849
    if (localAddress.startsWith("::ffff:")) { // IPv4-Mapped IPv6 Address https://tools.ietf.org/html/rfc4291#section-2.5.5.2
102✔
850
      localAddress = localAddress.substring(7);
12✔
851
    } else {
852
      const index = localAddress.indexOf("%");
90✔
853
      if (index !== -1) { // link-local ipv6
90!
NEW
854
        localAddress = localAddress.substring(0, index);
×
855
      }
856
    }
857

858
    const interfaces = os.networkInterfaces();
102✔
859
    for (const [name, infos] of Object.entries(interfaces)) {
102✔
860
      if (infos) {
102✔
861
        for (const info of infos) {
192✔
862
          if (info.address === localAddress) {
192✔
863
            return name;
102✔
864
          }
865
        }
866
      }
867
    }
868

869
    // we couldn't map the address from above, we try now to match subnets (see https://github.com/homebridge/HAP-NodeJS/issues/847)
UNCOV
870
    const family = net.isIPv4(localAddress)? "IPv4": "IPv6";
×
NEW
871
    for (const [name, infos] of Object.entries(interfaces)) {
×
872
      if (infos) {
×
UNCOV
873
        for (const info of infos) {
×
UNCOV
874
          if (info.family !== family) {
×
UNCOV
875
            continue;
×
876
          }
877

878
          // check if the localAddress is in the same subnet
UNCOV
879
          if (getNetAddress(localAddress, info.netmask) === getNetAddress(info.address, info.netmask)) {
×
UNCOV
880
            return name;
×
881
          }
882
        }
883
      }
884
    }
885

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

UNCOV
889
    return Object.keys(interfaces)[1]; // just use the first interface after the loopback interface as fallback
×
890
  }
891

892
}
42✔
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