UNPKG

homebridge-philips-hue-sync-box

Version:
151 lines (139 loc) 5.12 kB
import originalFetch from 'node-fetch'; import fetch_retry from 'fetch-retry'; import { Execution, Hue, State } from '../state.js'; import * as https from 'node:https'; import { Logger } from 'homebridge'; import { HueSyncBoxPlatformConfig } from '../config.js'; import AsyncLock, { AsyncLockOptions } 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: number, error: Error | null) => { return !!error && error.name !== 'AbortError' && attempt < HTTP_RETRY_COUNT; }, }); export class SyncBoxClient { private readonly LOCK_KEY = 'sync-box'; private readonly LOCK_OPTIONS: AsyncLockOptions = { timeout: SYNC_BOX_LOCK_QUEUE_TIMEOUT_MS, maxExecutionTime: SYNC_BOX_LOCK_MAX_EXECUTION_TIME_MS, }; private readonly lock: AsyncLock; private readonly agent = new https.Agent({ rejectUnauthorized: false, keepAlive: true, }); constructor( private readonly log: Logger | Console, private readonly config: HueSyncBoxPlatformConfig ) { this.lock = new AsyncLock(); } public async getState(): Promise<State | null> { return await this.lock .acquire( this.LOCK_KEY, async () => await this.sendRequest<State>('GET', ''), this.LOCK_OPTIONS ) .catch(e => { this.log.error('Failed to get state from Sync Box:', e); return null; }); } public async updateExecution(execution: Partial<Execution>): Promise<void> { return await this.lock .acquire( this.LOCK_KEY, async () => await this.sendRequest<void>('PUT', 'execution', execution), this.LOCK_OPTIONS ) .catch(e => { this.log.error('Error updating execution:', e); }); } public async updateHue(hue: Partial<Hue>): Promise<void> { return await this.lock .acquire( this.LOCK_KEY, async () => await this.sendRequest<void>('PUT', 'hue', hue), this.LOCK_OPTIONS ) .catch(e => { this.log.error('Error updating hue:', e); }); } private async sendRequest<T>( method: string, path: string, body?: Partial<Execution> | Partial<Hue> | null ): Promise<T> { 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 as T; } 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 as T; } }