homebridge-philips-hue-sync-box
Version:
Homebridge plugin for the Philips Hue Sync Box.
151 lines (139 loc) • 5.12 kB
text/typescript
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;
}
}