homebridge-xfinityhome
Version:
A homebridge plugin to control your Xfinity Home security system.
290 lines (261 loc) • 10.2 kB
text/typescript
/* eslint-disable no-console */
import debug from 'debug';
import { EventEmitter } from 'events';
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, unwatchFile, watch, watchFile } from 'fs';
import { Proxy } from 'http-mitm-proxy';
import os from 'os';
import path from 'path';
import qrcode from 'qrcode';
import { HomebridgePluginUiServer } from '@homebridge/plugin-ui-utils';
import type { PlatformAccessory } from 'homebridge';
import type { Device } from 'xfinityhome';
type CONTEXT = {
device: Device['device'];
logPath?: string;
refreshToken?: string;
};
class PluginUiServer extends HomebridgePluginUiServer {
constructor() {
super();
const events = new EventEmitter();
const plugin = 'homebridge-xfinityhome';
const platform = 'XfinityHomePlatform';
const storagePath = this.homebridgeStoragePath ?? '';
const configPath = this.homebridgeConfigPath ?? '';
const config = JSON.parse(readFileSync(configPath, 'utf-8')).platforms.find((plugin) => plugin.platform === platform);
/*
A native method getCachedAccessories() was introduced in config-ui-x v4.37.0
The following is for users who have a lower version of config-ui-x
*/
const cachedAccessoriesDir = path.join(storagePath, '/accessories/cachedAccessories') +
(config?._bridge?.username ? ('.' + config?._bridge?.username?.split(':').join('')) : '');
this.onRequest('/getCachedAccessories', async () => {
try {
// Define the plugin and create the array to return
if (existsSync(cachedAccessoriesDir)) {
return JSON.parse(readFileSync(cachedAccessoriesDir, 'utf-8')).filter(accessory => accessory.plugin === plugin);
} else {
return [];
}
} catch (err) {
// Just return an empty accessory list in case of any errors
console.log(err);
return [];
}
});
this.onRequest('/getGeneralLog', async () => {
return path.join(storagePath, 'XfinityHome', 'General.log');
});
this.onRequest('/getLogs', async (payload) => {
try {
return readFileSync(payload.logPath).toString().replace(/\n/g, '<br>');
} catch (err) {
return `Failed To Load Logs From ${payload.logPath}`;
}
});
this.onRequest('/getRelativePath', (payload) => {
return path.relative(path.join(__dirname, '/public/index.html'), payload.path);
});
this.onRequest('/deleteLog', async (payload) => {
try {
return rmSync(payload.logPath, { force: true });
} catch (err) {
return err;
}
});
this.onRequest('/watchLog', async (payload) => {
try {
return await watchFilePromise(payload.path);
} catch (err) {
return Promise.reject(err);
}
});
const watchFilePromise = async (file: string) => {
return new Promise((resolve, reject) => {
if (!existsSync(file)) {
reject('File does not exist: ' + file);
return;
}
try {
const aborter = new AbortController();
const watcher = watch(file, { signal: aborter.signal });
watcher.once('change', () => {
aborter.abort();
resolve(readFileSync(file));
});
watcher.once('error', err => {
console.error(err);
aborter.abort();
watchFile(file, () => {
unwatchFile(file);
resolve(readFileSync(file));
});
});
} catch {
watchFile(file, () => {
unwatchFile(file);
resolve(readFileSync(file));
});
}
});
};
this.onRequest('/watchAccessory', async payload => {
const loop = async () => {
const oldFile: PlatformAccessory<CONTEXT>[] =
JSON.parse(readFileSync(cachedAccessoriesDir, 'utf-8')).filter(accessory => accessory.plugin === plugin);
await watchFilePromise(cachedAccessoriesDir);
const newFile: PlatformAccessory<CONTEXT>[]
= JSON.parse(readFileSync(cachedAccessoriesDir, 'utf-8')).filter(accessory => accessory.plugin === plugin);
const oldAccessory = oldFile.find(accessory => accessory.UUID === payload.accessory.UUID);
const newAccessory = newFile.find(accessory => accessory.UUID === payload.accessory.UUID);
if (JSON.stringify(oldAccessory) !== JSON.stringify(newAccessory)) {
return newAccessory;
} else {
loop();
}
};
return loop();
});
this.onRequest('/proxyActive', async () => {
return new Promise((resolve) => {
events.on('proxy', () => resolve(''));
});
});
/*this.onRequest('/sslActive', async () => {
return new Promise((resolve) => {
events.on('ssl', () => resolve());
});
});*/
this.onRequest('/token', async () => {
return new Promise((resolve) => {
events.on('token', token => resolve(token));
});
});
this.onRequest('/startProxy', async () => {
// Disable debug messages from the proxy
try {
debug.disable();
} catch (err) {
//Do nothing
}
const ROOT = path.join(storagePath, 'XfinityHome');
if (!existsSync(ROOT)) {
mkdirSync(ROOT);
}
const pemFile = path.join(ROOT, 'certs', 'ca.pem');
const localIPs: string[] = [];
const ifaces = os.networkInterfaces();
Object.keys(ifaces).forEach(name => {
ifaces[name]?.forEach(network => {
const familyV4Value = typeof network.family === 'string' ? 'IPv4' : 4;
if (network.family === familyV4Value && !network.internal) {
localIPs.push(network.address);
}
});
});
localIPs.push(os.hostname() + os.hostname().endsWith('.local') ? '' : '.local');
const proxy = new Proxy();
const localIPPorts = localIPs.map(ip => `${ip}:${585}`);
proxy.onError((ctx, err) => {
switch (err?.name) {
case 'ERR_STREAM_DESTROYED':
case 'ECONNRESET':
return;
case 'ECONNREFUSED':
console.error('Failed to intercept secure communications. This could happen due to bad CA certificate.');
return;
case 'EACCES':
console.error(`Permission was denied to use port ${585}.`);
return;
default:
//console.error('Error:', err.code, err);
}
});
proxy.onRequest((ctx, callback) => {
if (ctx.clientToProxyRequest.method === 'GET' && ctx.clientToProxyRequest.url === '/cert' &&
localIPPorts.includes(ctx.clientToProxyRequest.headers.host ?? '')) {
ctx.use(Proxy.gunzip);
console.log('Intercepted certificate request');
ctx.proxyToClientResponse.writeHead(200, {
'Accept-Ranges': 'bytes',
'Cache-Control': 'public, max-age=0',
'Content-Type': 'application/x-x509-ca-cert',
'Content-Disposition': 'attachment; filename=cert.pem',
'Content-Transfer-Encoding': 'binary',
'Content-Length': statSync(pemFile).size,
'Connection': 'keep-alive',
});
//ctx.proxyToClientResponse.end(fs.readFileSync(path.join(ROOT, 'certs', 'ca.pem')));
ctx.proxyToClientResponse.write(readFileSync(pemFile));
ctx.proxyToClientResponse.end();
return;
} else if (ctx.clientToProxyRequest.method === 'POST' && ctx.clientToProxyRequest.headers.host === 'oauth.xfinity.com' &&
ctx.clientToProxyRequest.url === '/oauth/token') {
ctx.use(Proxy.gunzip);
ctx.onRequestData((ctx, chunk, callback) => {
return callback(undefined, chunk);
});
ctx.onRequestEnd((ctx, callback) => {
callback();
});
const chunks: Buffer[] = [];
ctx.onResponseData((ctx, chunk, callback) => {
chunks.push(chunk);
return callback(undefined, chunk);
});
ctx.onResponseEnd((ctx, callback) => {
events.emit('token', JSON.parse(Buffer.concat(chunks).toString()).refresh_token);
//token = JSON.parse(Buffer.concat(chunks).toString()).refresh_token;
//this.pushEvent('token', { refreshToken: JSON.parse(Buffer.concat(chunks).toString()).refresh_token });
//emitter.emit('tuya-config', Buffer.concat(chunks).toString());
callback();
});
} else {
//this.pushEvent('proxy', {});
events.emit('proxy');
/*ctx.onRequestData(function (ctx, chunk, callback) {
ctx.onResponseData(function (ctx, chunk, callback) {
//this.pushEvent('sslProxy', {});
events.emit('ssl');
});
});*/
}
return callback();
});
/*emitter.on('tuya-config', body => {
//if (body.indexOf('tuya.m.my.group.device.list') === -1) return;
console.log('Intercepted token from Xfinity Home');
try {
console.log('Your refresh token is: ' + JSON.parse(body).refresh_token);
} catch (err) {
console.error(err);
}
});*/
this.onRequest('/stopProxy', () => {
if (proxy && typeof proxy.close === 'function') {
proxy.close();
}
if (existsSync(path.join(ROOT, 'certs'))) {
rmSync(path.join(ROOT, 'certs'), { recursive: true, force: true });
}
if (existsSync(path.join(ROOT, 'keys'))) {
rmSync(path.join(ROOT, 'keys'), { recursive: true, force: true });
}
return '';
});
return new Promise((resolve) => {
proxy.listen({ host: '::', port: 585, sslCaDir: ROOT }, async err => {
if (err) {
console.error('Error starting proxy: ' + err);
}
const address = localIPs[0];
const port = 585;
qrcode.toString(`http://${address}:${port}/cert`, { type: 'svg' })
.then(qrcode => resolve({ ip: address, port: port, qrcode: qrcode }));
});
});
});
this.ready();
}
}
(() => new PluginUiServer())();