homebridge-philips-hue-sync-box
Version:
Homebridge plugin for the Philips Hue Sync Box.
109 lines • 4.81 kB
JavaScript
import originalFetch from 'node-fetch';
import fetch_retry from 'fetch-retry';
import * as https from 'node:https';
import AsyncLock from 'async-lock';
import { HTTP_RETRY_COUNT, HTTP_RETRY_BASE_DELAY_MS, MAX_SYNC_BOX_RESPONSE_BYTES, SYNC_BOX_REQUEST_TIMEOUT_MS, SYNC_BOX_LOCK_QUEUE_TIMEOUT_MS, SYNC_BOX_LOCK_MAX_EXECUTION_TIME_MS, } from './constants.js';
import { isValidState } from './validation.js';
const fetch = fetch_retry(originalFetch, {
retries: HTTP_RETRY_COUNT,
retryDelay: attempt => {
return Math.pow(2, attempt) * HTTP_RETRY_BASE_DELAY_MS; // 1000, 2000, 4000
},
// fetch-retry reuses the same request `init` - and so the same
// AbortSignal - across every retry attempt. Once our own request timeout
// fires, the signal is permanently aborted, so retrying just burns the
// full backoff delay on attempts that fail instantly for no benefit.
// Real network errors (connection refused/reset, DNS failure, etc.) still
// get retried normally.
//
// fetch-retry only enforces its own `retries` option when `retryOn` is an
// array; a function `retryOn` fully replaces that bound, so this has to
// check `attempt` itself or a persistent network error retries forever.
retryOn: (attempt, error) => {
return !!error && error.name !== 'AbortError' && attempt < HTTP_RETRY_COUNT;
},
});
export class SyncBoxClient {
log;
config;
LOCK_KEY = 'sync-box';
LOCK_OPTIONS = {
timeout: SYNC_BOX_LOCK_QUEUE_TIMEOUT_MS,
maxExecutionTime: SYNC_BOX_LOCK_MAX_EXECUTION_TIME_MS,
};
lock;
agent = new https.Agent({
rejectUnauthorized: false,
keepAlive: true,
});
constructor(log, config) {
this.log = log;
this.config = config;
this.lock = new AsyncLock();
}
async getState() {
return await this.lock
.acquire(this.LOCK_KEY, async () => await this.sendRequest('GET', ''), this.LOCK_OPTIONS)
.catch(e => {
this.log.error('Failed to get state from Sync Box:', e);
return null;
});
}
async updateExecution(execution) {
return await this.lock
.acquire(this.LOCK_KEY, async () => await this.sendRequest('PUT', 'execution', execution), this.LOCK_OPTIONS)
.catch(e => {
this.log.error('Error updating execution:', e);
});
}
async updateHue(hue) {
return await this.lock
.acquire(this.LOCK_KEY, async () => await this.sendRequest('PUT', 'hue', hue), this.LOCK_OPTIONS)
.catch(e => {
this.log.error('Error updating hue:', e);
});
}
async sendRequest(method, path, body) {
const url = `https://${this.config.syncBoxIpAddress}/api/v1/${path}`;
const options = {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.config.syncBoxApiAccessToken}`,
},
method,
body: body ? JSON.stringify(body) : null,
agent: this.agent,
// Aborts the response stream once it exceeds this many bytes, so a
// spoofed oversized response can't be fully buffered by res.json()
// before isValidState() gets a chance to reject it.
size: MAX_SYNC_BOX_RESPONSE_BYTES,
// Bounds the whole request (all retries included - see the retryOn
// override above) in case the Sync Box accepts the connection but
// never sends a response; node-fetch applies no timeout on its own.
signal: AbortSignal.timeout(SYNC_BOX_REQUEST_TIMEOUT_MS),
};
this.log.debug('Request to Sync Box:', url, JSON.stringify({
...options,
headers: { ...options.headers, Authorization: '[REDACTED]' },
}));
const res = await fetch(url, options);
if (!res.ok) {
this.log.error(`Error: ${res.status} - ${res.statusText}. ${JSON.stringify(await res.json())}`);
throw new Error(`Error: ${res.status} - ${res.statusText}`);
}
if (method !== 'GET') {
return null;
}
const json = await res.json();
// The Sync Box's cert is accepted without identity verification
// (rejectUnauthorized: false), so a LAN-adjacent attacker able to spoof
// its responses must not be able to hand every accessory's update() a
// shape it dereferences unconditionally - that already crashed the
// entire Homebridge process, not just this plugin's accessories.
if (!isValidState(json)) {
throw new Error('Sync Box returned a malformed state response.');
}
return json;
}
}
//# sourceMappingURL=client.js.map