UNPKG

hap-controller

Version:

Library to implement a HAP (HomeKit) controller

362 lines 13.7 kB
"use strict"; /** * Class to represent a multi-request HTTP connection. */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const events_1 = require("events"); const http_event_parser_1 = __importDefault(require("./http-event-parser")); const net_1 = __importDefault(require("net")); const libsodium_wrappers_1 = __importDefault(require("libsodium-wrappers")); const http_parser_js_1 = require("http-parser-js"); const debug_1 = __importDefault(require("debug")); const queue_1 = require("../../utils/queue"); const debug = (0, debug_1.default)('hap-controller:http-connection'); /** * Internal socket state. */ var State; (function (State) { State[State["CLOSED"] = 0] = "CLOSED"; State[State["OPENING"] = 1] = "OPENING"; State[State["READY"] = 2] = "READY"; State[State["CLOSING"] = 3] = "CLOSING"; })(State || (State = {})); class HttpConnection extends events_1.EventEmitter { /** * Initialize the HttpConnection object. * * @param {string} address - IP address of the device * @param {number} port - HTTP port */ constructor(address, port) { super(); this.address = address; this.port = port; this.state = State.CLOSED; this.socket = null; this.sessionKeys = null; this.a2cCounter = 0; this.c2aCounter = 0; this.queue = new queue_1.OpQueue(); } /** * Set the session keys for the connection. * * @param {Object} keys - The session key object obtained from PairingProtocol */ setSessionKeys(keys) { this.sessionKeys = keys; } /** * Get the State of the connection * * @returns {Boolean} Connection State */ isConnected() { return this.state === State.READY; } /** * Queue an operation for the connection. * * @param {function} op - Function to add to the queue * @returns {Promise} Promise which resolves when the function is called. */ _queueOperation(op) { return this.queue.queue(op); } /** * Open a socket if necessary. * * @returns {Promise} Promise which resolves when the socket is open and * ready. */ async _open() { if (this.state === State.READY) { return; } else if (this.state !== State.CLOSED && this.socket) { this.socket.end(); } return new Promise((resolve, reject) => { this.state = State.CLOSED; try { this.socket = net_1.default.createConnection(this.port, this.address); this.socket.setKeepAlive(true); this.socket.on('close', () => { this.socket = null; this.state = State.CLOSED; this.emit('disconnect', {}); }); this.socket.on('end', () => { var _a; this.state = State.CLOSING; (_a = this.socket) === null || _a === void 0 ? void 0 : _a.end(); }); this.socket.on('timeout', () => { var _a; this.state = State.CLOSING; (_a = this.socket) === null || _a === void 0 ? void 0 : _a.end(); }); this.socket.on('error', (err) => { reject(err); }); this.socket.on('connect', () => { this.state = State.READY; resolve(); }); } catch (err) { reject(err); } }); } /** * Send a GET request. * * @param {string} path - Path to request * @returns {Promise} Promise which resolves to a buffer containing the * response body. */ get(path) { debug(`${this.address}:${this.port} GET ${path}`); const data = Buffer.concat([ Buffer.from(`GET ${path} HTTP/1.1\r\n`), Buffer.from(`Host: ${this.address}:${this.port}\r\n`), Buffer.from(`\r\n`), ]); return this.request(data); } /** * Send a POST request. * * @param {string} path - Path to request * @param {Buffer|string} body - Request body * @param {string?} contentType - Request content type * @returns {Promise} Promise which resolves to a buffer containing the * response body. */ post(path, body, contentType = 'application/hap+json') { if (typeof body === 'string') { body = Buffer.from(body); } debug(`${this.address}:${this.port} POST ${path} ${body.toString('hex')} (${body.length ? contentType : 'no content'})`); const data = Buffer.concat([ Buffer.from(`POST ${path} HTTP/1.1\r\n`), Buffer.from(`Host: ${this.address}:${this.port}\r\n`), body.length ? Buffer.from(`Content-Type: ${contentType}\r\n`) : Buffer.from(''), Buffer.from(`Content-Length: ${body.length}\r\n`), Buffer.from(`\r\n`), body, ]); return this.request(data); } /** * Send a PUT request. * * @param {string} path - Path to request * @param {Buffer|string} body - Request body * @param {string?} contentType - Request content type * @param {boolean?} readEvents - Whether or not to read EVENT messages after * initial request * @returns {Promise} Promise which resolves to a buffer containing the * response body. */ put(path, body, contentType = 'application/hap+json', readEvents = false) { if (typeof body === 'string') { body = Buffer.from(body); } debug(`${this.address}:${this.port} PUT ${path} ${body.toString('hex')}`); const data = Buffer.concat([ Buffer.from(`PUT ${path} HTTP/1.1\r\n`), Buffer.from(`Host: ${this.address}:${this.port}\r\n`), Buffer.from(`Content-Type: ${contentType}\r\n`), Buffer.from(`Content-Length: ${body.length}\r\n`), Buffer.from(`\r\n`), body, ]); return this.request(data, readEvents); } /** * Send a request. * * @param {Buffer} body - Request body * @param {boolean?} readEvents - Whether or not to read EVENT messages after * initial request * @returns {Promise} Promise which resolves to a buffer containing the * response body. */ request(body, readEvents = false) { return this._queueOperation(async () => { if (this.sessionKeys) { return this._requestEncrypted(body, readEvents); } return this._requestClear(body, readEvents); }); } /** * Encrypt request data. * * @param {Buffer} data - Data to encrypt * @returns {Buffer} Encrypted data. */ _encryptData(data) { const encryptedData = []; let position = 0; while (position < data.length) { const writeNonce = Buffer.alloc(12); writeNonce.writeUInt32LE(this.c2aCounter++, 4); const frameLength = Math.min(data.length - position, 1024); const aad = Buffer.alloc(2); aad.writeUInt16LE(frameLength, 0); const frame = Buffer.from(libsodium_wrappers_1.default.crypto_aead_chacha20poly1305_ietf_encrypt(data.slice(position, position + frameLength), aad, null, writeNonce, this.sessionKeys.ControllerToAccessoryKey)); encryptedData.push(aad); encryptedData.push(frame); position += frameLength; } return Buffer.concat(encryptedData); } /** * Create an HTTP response parser. * * @param {(response: HttpResponse) => void} resolve - Function to call with response * @returns {Object} HTTPParser object. */ _buildHttpResponseParser(resolve) { const parser = new http_parser_js_1.HTTPParser(http_parser_js_1.HTTPParser.RESPONSE); const headers = {}; parser.onHeadersComplete = (res) => { for (let i = 0; i < res.headers.length; i += 2) { headers[res.headers[i]] = res.headers[i + 1]; } }; let body = Buffer.alloc(0); parser.onBody = (chunk, start, len) => { body = Buffer.concat([body, chunk.slice(start, start + len)]); }; parser.onMessageComplete = () => { resolve({ statusCode: parser.info.statusCode, headers, body, }); }; return parser; } /** * Send an encrypted request. * * @param {Buffer} data - Request body * @param {boolean?} readEvents - Whether or not to read EVENT messages after * initial request * @returns {Promise} Promise which resolves to a buffer containing the * response body. */ async _requestEncrypted(data, readEvents = false) { await libsodium_wrappers_1.default.ready; await this._open(); return new Promise((resolve, reject) => { const oldListeners = this.socket.listeners('data'); this.socket.removeAllListeners('data'); try { this.socket.write(this._encryptData(data)); } catch (err) { return reject(err); } let message = Buffer.alloc(0); // eslint-disable-next-line prefer-const let parser; const bodyParser = (chunk) => { message = Buffer.concat([message, chunk]); while (message.length >= 18) { const frameLength = message.readUInt16LE(0); if (message.length < frameLength + 18) { return; } const aad = message.slice(0, 2); const data = message.slice(2, 18 + frameLength); const readNonce = Buffer.alloc(12); readNonce.writeUInt32LE(this.a2cCounter, 4); try { const decryptedData = Buffer.from(libsodium_wrappers_1.default.crypto_aead_chacha20poly1305_ietf_decrypt(null, data, aad, readNonce, this.sessionKeys.AccessoryToControllerKey)); message = message.slice(18 + frameLength, message.length); ++this.a2cCounter; parser.execute(decryptedData); } catch (e) { // pass } } }; parser = this._buildHttpResponseParser((response) => { this.socket.removeListener('data', bodyParser); for (const l of oldListeners) { this.socket.on('data', l); } if (readEvents) { parser = new http_event_parser_1.default(); parser.on('event', (ev) => this.emit('event', ev)); this.socket.on('data', bodyParser); } debug(`${this.address}:${this.port} ` + `Response ${response.statusCode} with ${response.body.length} byte data`); resolve(response); }); this.socket.on('data', bodyParser); }); } /** * Send a clear-text request. * * @param {Buffer} data - Request body * @param {boolean?} readEvents - Whether or not to read EVENT messages after * initial request * @returns {Promise} Promise which resolves to a buffer containing the * response body. */ async _requestClear(data, readEvents = false) { await this._open(); return new Promise((resolve, reject) => { const oldListeners = this.socket.listeners('data'); this.socket.removeAllListeners('data'); try { this.socket.write(data); } catch (err) { return reject(err); } // eslint-disable-next-line prefer-const let parser; const bodyParser = (chunk) => { parser.execute(chunk); }; parser = this._buildHttpResponseParser((response) => { this.socket.removeListener('data', bodyParser); for (const l of oldListeners) { this.socket.on('data', l); } if (readEvents) { parser = new http_event_parser_1.default(); parser.on('event', (ev) => this.emit('event', ev)); this.socket.on('data', bodyParser); } debug(`${this.address}:${this.port} ` + `Response ${response.statusCode} with ${response.body.length} byte data`); resolve(response); }); this.socket.on('data', bodyParser); }); } /** * Close the socket. */ close() { var _a; (_a = this.socket) === null || _a === void 0 ? void 0 : _a.end(); } } exports.default = HttpConnection; //# sourceMappingURL=http-connection.js.map