hap-controller
Version:
Library to implement a HAP (HomeKit) controller
362 lines • 13.7 kB
JavaScript
"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