feat: add bounded large-deployment discovery
This commit is contained in:
+39
-12
@@ -3,13 +3,24 @@
|
||||
const { canonicalizeAltaOrigin, assertSameAltaOrigin } = require('./url-policy');
|
||||
const { formatAltaError } = require('./error-redaction');
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEVICE_MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
|
||||
const HIERARCHY_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
||||
const AUTH_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
||||
const DEFAULT_MAX_RESPONSE_BYTES = DEVICE_MAX_RESPONSE_BYTES;
|
||||
const MAX_ARRAY_OBJECTS = 10_000;
|
||||
const DEFAULT_MAX_REDIRECTS = 3;
|
||||
const ENDPOINTS = Object.freeze({
|
||||
getDevices: Object.freeze({ path: '/api/v1/devices', shape: 'array' }),
|
||||
getDeviceSites: Object.freeze({ path: '/api/v1/deviceSites', shape: 'array' }),
|
||||
getAuthInfo: Object.freeze({ path: '/api/v1/auth', shape: 'object' }),
|
||||
getDevices: Object.freeze({
|
||||
path: '/api/v1/devices', shape: 'array', maxResponseBytes: DEVICE_MAX_RESPONSE_BYTES, maxObjects: MAX_ARRAY_OBJECTS,
|
||||
}),
|
||||
getDeviceSites: Object.freeze({
|
||||
path: '/api/v1/deviceSites', shape: 'array', maxResponseBytes: HIERARCHY_MAX_RESPONSE_BYTES, maxObjects: MAX_ARRAY_OBJECTS,
|
||||
}),
|
||||
getDeviceGroups: Object.freeze({
|
||||
path: '/api/v1/deviceGroups', shape: 'array', maxResponseBytes: HIERARCHY_MAX_RESPONSE_BYTES, maxObjects: MAX_ARRAY_OBJECTS,
|
||||
}),
|
||||
getAuthInfo: Object.freeze({ path: '/api/v1/auth', shape: 'object', maxResponseBytes: AUTH_MAX_RESPONSE_BYTES }),
|
||||
});
|
||||
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
||||
|
||||
@@ -118,7 +129,7 @@ class AltaClient {
|
||||
}
|
||||
if (typeof transport !== 'function') throw new TypeError('AltaClient transport must be a function');
|
||||
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > DEFAULT_TIMEOUT_MS) {
|
||||
throw new RangeError('AltaClient timeout must be between 1 and 10000ms');
|
||||
throw new RangeError('AltaClient timeout must be between 1 and 30000ms');
|
||||
}
|
||||
if (!Number.isInteger(maxResponseBytes) || maxResponseBytes < 1 || maxResponseBytes > DEFAULT_MAX_RESPONSE_BYTES) {
|
||||
throw new RangeError('Invalid Alta response size limit');
|
||||
@@ -141,6 +152,10 @@ class AltaClient {
|
||||
return this.#invoke('getDeviceSites', args);
|
||||
}
|
||||
|
||||
getDeviceGroups(...args) {
|
||||
return this.#invoke('getDeviceGroups', args);
|
||||
}
|
||||
|
||||
getAuthInfo(...args) {
|
||||
return this.#invoke('getAuthInfo', args);
|
||||
}
|
||||
@@ -150,6 +165,7 @@ class AltaClient {
|
||||
throw altaError('INVALID_ALTA_ARGUMENTS', 'Alta API methods do not accept renderer parameters');
|
||||
}
|
||||
const endpoint = ENDPOINTS[operation];
|
||||
const maxResponseBytes = Math.min(this.maxResponseBytes, endpoint.maxResponseBytes);
|
||||
const session = this.sessionStore.requireSession();
|
||||
const origin = canonicalizeAltaOrigin(session.origin);
|
||||
const cookie = session.cookie;
|
||||
@@ -157,10 +173,15 @@ class AltaClient {
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
const data = await this.#request(operation, `${origin}${endpoint.path}`, origin, cookie, deadline, controller, 0);
|
||||
const data = await this.#request(
|
||||
operation, `${origin}${endpoint.path}`, origin, cookie, deadline, controller, 0, maxResponseBytes,
|
||||
);
|
||||
if (endpoint.shape === 'array' && !Array.isArray(data)) {
|
||||
throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response did not contain an array');
|
||||
}
|
||||
if (endpoint.shape === 'array' && data.length > endpoint.maxObjects) {
|
||||
throw altaError('ALTA_RESPONSE_TOO_MANY_OBJECTS', 'Alta response exceeded the object limit');
|
||||
}
|
||||
if (endpoint.shape === 'object' && (!data || typeof data !== 'object' || Array.isArray(data))) {
|
||||
throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response did not contain an object');
|
||||
}
|
||||
@@ -172,7 +193,7 @@ class AltaClient {
|
||||
}
|
||||
}
|
||||
|
||||
async #request(operation, url, origin, cookie, deadline, controller, redirectCount) {
|
||||
async #request(operation, url, origin, cookie, deadline, controller, redirectCount, maxResponseBytes) {
|
||||
assertSameAltaOrigin(url, origin);
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) throw altaError('ALTA_TIMEOUT', 'Alta request timed out', { timeout: true });
|
||||
@@ -196,7 +217,7 @@ class AltaClient {
|
||||
signal: controller.signal,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxResponseBytes: this.maxResponseBytes,
|
||||
maxResponseBytes,
|
||||
}))),
|
||||
timeoutPromise,
|
||||
]);
|
||||
@@ -209,7 +230,7 @@ class AltaClient {
|
||||
}
|
||||
const headers = normalizeHeaders(response.headers);
|
||||
// Bound every response, including redirects and errors, before acting on it.
|
||||
enforceResponseSize(response.data, headers, this.maxResponseBytes);
|
||||
enforceResponseSize(response.data, headers, maxResponseBytes);
|
||||
|
||||
if (REDIRECT_STATUSES.has(response.status)) {
|
||||
if (typeof headers.location !== 'string' || headers.location.length === 0) {
|
||||
@@ -225,14 +246,16 @@ class AltaClient {
|
||||
} catch {
|
||||
throw altaError('UNSAFE_ALTA_REDIRECT', 'Alta redirect changed the validated origin');
|
||||
}
|
||||
return this.#request(operation, target.href, origin, cookie, deadline, controller, redirectCount + 1);
|
||||
return this.#request(
|
||||
operation, target.href, origin, cookie, deadline, controller, redirectCount + 1, maxResponseBytes,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status < 200 || response.status > 299) {
|
||||
throw altaError('ALTA_HTTP_ERROR', 'Alta request returned an error status', { status: response.status });
|
||||
}
|
||||
|
||||
return parseData(response.data, this.maxResponseBytes);
|
||||
return parseData(response.data, maxResponseBytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,10 +265,14 @@ function createAltaClient(options) {
|
||||
|
||||
module.exports = {
|
||||
AltaClient,
|
||||
AUTH_MAX_RESPONSE_BYTES,
|
||||
DEFAULT_MAX_REDIRECTS,
|
||||
DEFAULT_MAX_RESPONSE_BYTES,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
DEVICE_MAX_RESPONSE_BYTES,
|
||||
ENDPOINTS,
|
||||
HIERARCHY_MAX_RESPONSE_BYTES,
|
||||
MAX_ARRAY_OBJECTS,
|
||||
axiosTransport,
|
||||
createAltaClient,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
'use strict';
|
||||
|
||||
const { validateDeviceId } = require('./proxy-launch');
|
||||
|
||||
const MAX_PROJECTED_PAYLOAD_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
function projectionError(code, message) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function nullableString(value) {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function nullableId(value) {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function nullableBoolean(value) {
|
||||
return typeof value === 'boolean' ? value : null;
|
||||
}
|
||||
|
||||
function projectDevice(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
let id;
|
||||
try {
|
||||
id = validateDeviceId(raw.guid);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
name: nullableString(raw.name),
|
||||
type: nullableString(raw.type),
|
||||
model: nullableString(raw.model),
|
||||
address: nullableString(raw.address),
|
||||
siteId: nullableId(raw.server_group_id),
|
||||
deviceGroupId: nullableId(raw.device_group_id),
|
||||
displayStatus: nullableString(raw.live && raw.live.display_status),
|
||||
localStorage: nullableBoolean(raw.capabilities && raw.capabilities.localStorage),
|
||||
};
|
||||
}
|
||||
|
||||
function projectSite(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const id = nullableId(raw.id);
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
name: nullableString(raw.name),
|
||||
pendingDeletionStart: nullableString(raw.pending_deletion_start),
|
||||
};
|
||||
}
|
||||
|
||||
function projectGroup(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const id = nullableId(raw.id);
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
name: nullableString(raw.name),
|
||||
parentId: nullableId(raw.parent_id),
|
||||
pendingDeletionStart: nullableString(raw.pending_deletion_start),
|
||||
};
|
||||
}
|
||||
|
||||
function projectArray(values, projector) {
|
||||
const projected = [];
|
||||
let invalid = 0;
|
||||
for (const value of Array.isArray(values) ? values : []) {
|
||||
const item = projector(value);
|
||||
if (item) projected.push(item);
|
||||
else invalid += 1;
|
||||
}
|
||||
return { projected, invalid };
|
||||
}
|
||||
|
||||
function safeMetadataError(value) {
|
||||
return typeof value === 'string' && value.length > 0 ? value.slice(0, 512) : null;
|
||||
}
|
||||
|
||||
function projectDeviceHierarchy({ devices = [], sites = [], groups = [], metadataErrors = {} } = {}) {
|
||||
if (!Array.isArray(devices) || !Array.isArray(sites) || !Array.isArray(groups)) {
|
||||
throw new TypeError('Hierarchy projection requires arrays');
|
||||
}
|
||||
const deviceProjection = projectArray(devices, projectDevice);
|
||||
const siteProjection = projectArray(sites, projectSite);
|
||||
const groupProjection = projectArray(groups, projectGroup);
|
||||
const payload = {
|
||||
devices: deviceProjection.projected,
|
||||
sites: siteProjection.projected,
|
||||
groups: groupProjection.projected,
|
||||
diagnostics: {
|
||||
devices: {
|
||||
received: devices.length,
|
||||
eligible: deviceProjection.projected.length,
|
||||
invalid: deviceProjection.invalid,
|
||||
},
|
||||
sites: {
|
||||
received: sites.length,
|
||||
projected: siteProjection.projected.length,
|
||||
invalid: siteProjection.invalid,
|
||||
error: safeMetadataError(metadataErrors.sites),
|
||||
},
|
||||
groups: {
|
||||
received: groups.length,
|
||||
projected: groupProjection.projected.length,
|
||||
invalid: groupProjection.invalid,
|
||||
error: safeMetadataError(metadataErrors.groups),
|
||||
},
|
||||
},
|
||||
};
|
||||
if (Buffer.byteLength(JSON.stringify(payload)) > MAX_PROJECTED_PAYLOAD_BYTES) {
|
||||
throw projectionError('PROJECTED_PAYLOAD_TOO_LARGE', 'Projected Alta hierarchy exceeded the IPC size limit');
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_PROJECTED_PAYLOAD_BYTES,
|
||||
projectDevice,
|
||||
projectDeviceHierarchy,
|
||||
projectGroup,
|
||||
projectSite,
|
||||
};
|
||||
+74
-8
@@ -10,14 +10,18 @@ const {
|
||||
readJsonBody,
|
||||
} = require('./bridge-auth');
|
||||
const { RELEASES_PAGE_URL } = require('./update-policy');
|
||||
const { projectDeviceHierarchy, projectGroup, projectSite } = require('./device-projection');
|
||||
const { validateDeviceId, validateUsername } = require('./proxy-launch');
|
||||
|
||||
const DEFAULT_DISCOVERY_TIMEOUT_MS = 60_000;
|
||||
|
||||
const PAIRING_ENVELOPE_FILENAME = 'bridge-pairing.json';
|
||||
|
||||
function safeErrorMessage(error, fallback) {
|
||||
const allowed = new Set([
|
||||
'NO_ALTA_SESSION', 'INVALID_ALTA_ARGUMENTS', 'ALTA_TIMEOUT', 'ALTA_HTTP_ERROR',
|
||||
'INVALID_ALTA_RESPONSE', 'INVALID_ALTA_RESPONSE_DATA', 'ALTA_RESPONSE_TOO_LARGE',
|
||||
'ALTA_RESPONSE_TOO_MANY_OBJECTS', 'PROJECTED_PAYLOAD_TOO_LARGE',
|
||||
'UNSAFE_ALTA_REDIRECT', 'TOO_MANY_ALTA_REDIRECTS', 'HELPER_NOT_FOUND',
|
||||
'UNSUPPORTED_PLATFORM', 'INVALID_DEVICE_ID', 'INVALID_USERNAME', 'SPAWN_FAILED',
|
||||
]);
|
||||
@@ -196,13 +200,19 @@ class AppRuntime {
|
||||
checkForUpdate,
|
||||
currentVersion,
|
||||
openExternal,
|
||||
discoveryTimeoutMs = DEFAULT_DISCOVERY_TIMEOUT_MS,
|
||||
} = {}) {
|
||||
if (!Number.isInteger(discoveryTimeoutMs) || discoveryTimeoutMs < 1 ||
|
||||
discoveryTimeoutMs > DEFAULT_DISCOVERY_TIMEOUT_MS) {
|
||||
throw new RangeError('AppRuntime discovery timeout must be between 1 and 60000ms');
|
||||
}
|
||||
this.sessionStore = sessionStore;
|
||||
this.altaClient = altaClient;
|
||||
this.proxyManager = proxyManager;
|
||||
this.checkForUpdatePolicy = checkForUpdate;
|
||||
this.currentVersion = currentVersion;
|
||||
this.openExternal = openExternal;
|
||||
this.discoveryTimeoutMs = discoveryTimeoutMs;
|
||||
this.allowedDeviceIds = new Set();
|
||||
this.proxyByDevice = new Map();
|
||||
}
|
||||
@@ -223,15 +233,17 @@ class AppRuntime {
|
||||
return tracked.filter((proxy) => this.proxyByDevice.get(proxy.deviceId) === proxy.processId);
|
||||
}
|
||||
|
||||
_setAllowedDevices(devices) {
|
||||
this.allowedDeviceIds = new Set(devices.map((device) => device.id));
|
||||
}
|
||||
|
||||
async getDevices() {
|
||||
try {
|
||||
const devices = await this.altaClient.getDevices();
|
||||
this.allowedDeviceIds = new Set();
|
||||
for (const device of devices) {
|
||||
const candidate = device && (device.guid || device.id);
|
||||
try { this.allowedDeviceIds.add(validateDeviceId(candidate)); } catch {}
|
||||
}
|
||||
return { success: true, devices };
|
||||
const hierarchy = projectDeviceHierarchy({
|
||||
devices: await this.altaClient.getDevices(), sites: [], groups: [],
|
||||
});
|
||||
this._setAllowedDevices(hierarchy.devices);
|
||||
return { success: true, devices: hierarchy.devices };
|
||||
} catch (error) {
|
||||
return { success: false, devices: [], message: safeErrorMessage(error, 'Failed to get devices') };
|
||||
}
|
||||
@@ -239,12 +251,66 @@ class AppRuntime {
|
||||
|
||||
async getDeviceSites() {
|
||||
try {
|
||||
return { success: true, sites: await this.altaClient.getDeviceSites() };
|
||||
const sites = (await this.altaClient.getDeviceSites()).map(projectSite).filter(Boolean);
|
||||
return { success: true, sites };
|
||||
} catch (error) {
|
||||
return { success: false, sites: [], message: safeErrorMessage(error, 'Failed to get device sites') };
|
||||
}
|
||||
}
|
||||
|
||||
async getDeviceGroups() {
|
||||
try {
|
||||
const groups = (await this.altaClient.getDeviceGroups()).map(projectGroup).filter(Boolean);
|
||||
return { success: true, groups };
|
||||
} catch (error) {
|
||||
return { success: false, groups: [], message: safeErrorMessage(error, 'Failed to get device groups') };
|
||||
}
|
||||
}
|
||||
|
||||
async getDeviceHierarchy() {
|
||||
let timer;
|
||||
try {
|
||||
const discovery = Promise.allSettled([
|
||||
this.altaClient.getDevices(),
|
||||
this.altaClient.getDeviceSites(),
|
||||
this.altaClient.getDeviceGroups(),
|
||||
]);
|
||||
const deadline = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
const error = new Error('Alta device discovery timed out');
|
||||
error.code = 'DISCOVERY_TIMEOUT';
|
||||
reject(error);
|
||||
}, this.discoveryTimeoutMs);
|
||||
});
|
||||
const [deviceResult, siteResult, groupResult] = await Promise.race([discovery, deadline]);
|
||||
if (deviceResult.status === 'rejected') throw deviceResult.reason;
|
||||
|
||||
const metadataErrors = {};
|
||||
if (siteResult.status === 'rejected') {
|
||||
metadataErrors.sites = 'Device sites unavailable';
|
||||
}
|
||||
if (groupResult.status === 'rejected') {
|
||||
metadataErrors.groups = 'Device groups unavailable';
|
||||
}
|
||||
const hierarchy = projectDeviceHierarchy({
|
||||
devices: deviceResult.value,
|
||||
sites: siteResult.status === 'fulfilled' ? siteResult.value : [],
|
||||
groups: groupResult.status === 'fulfilled' ? groupResult.value : [],
|
||||
metadataErrors,
|
||||
});
|
||||
this._setAllowedDevices(hierarchy.devices);
|
||||
return { success: true, hierarchy };
|
||||
} catch (error) {
|
||||
this.allowedDeviceIds.clear();
|
||||
const message = error && error.code === 'DISCOVERY_TIMEOUT'
|
||||
? 'Alta device discovery timed out'
|
||||
: safeErrorMessage(error, 'Failed to discover Alta devices');
|
||||
return { success: false, hierarchy: { devices: [], sites: [], groups: [] }, message };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthInfo() {
|
||||
try {
|
||||
return { success: true, authInfo: await this.altaClient.getAuthInfo() };
|
||||
|
||||
Reference in New Issue
Block a user