fix: add shell-free proxy process manager
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
'use strict';
|
||||
|
||||
const nodeFs = require('node:fs');
|
||||
const nodePath = require('node:path');
|
||||
const { spawn: nodeSpawn } = require('node:child_process');
|
||||
|
||||
const HELPER_FILENAME = 'aware-cam-proxy.exe';
|
||||
const MAX_COOKIE_LENGTH = 4096;
|
||||
const MAX_HOST_LENGTH = 253;
|
||||
const DEVICE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const ALTA_SUFFIXES = ['.avasecurity.com', '.avigilon.com'];
|
||||
|
||||
class ProxyLaunchError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'ProxyLaunchError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function validateDeploymentHost(value) {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_HOST_LENGTH) {
|
||||
throw new ProxyLaunchError('Deployment host is invalid.', 'INVALID_DEPLOYMENT_HOST');
|
||||
}
|
||||
|
||||
const host = value.toLowerCase();
|
||||
const labels = host.split('.');
|
||||
const hasAltaSuffix = ALTA_SUFFIXES.some((suffix) => host.endsWith(suffix));
|
||||
if (!hasAltaSuffix || labels.some((label) => !DNS_LABEL_PATTERN.test(label))) {
|
||||
throw new ProxyLaunchError('Deployment host is invalid.', 'INVALID_DEPLOYMENT_HOST');
|
||||
}
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
function validateDeviceId(value) {
|
||||
if (typeof value !== 'string' || value.length !== 36 || !DEVICE_ID_PATTERN.test(value)) {
|
||||
throw new ProxyLaunchError('Device identifier is invalid.', 'INVALID_DEVICE_ID');
|
||||
}
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
function validateCookie(value) {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_COOKIE_LENGTH) {
|
||||
throw new ProxyLaunchError('Cookie is invalid.', 'INVALID_COOKIE');
|
||||
}
|
||||
if (/[\u0000-\u001f\u007f]/.test(value)) {
|
||||
throw new ProxyLaunchError('Cookie must not contain control characters.', 'INVALID_COOKIE');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function redactMessage(error, secret) {
|
||||
const source = error && typeof error.message === 'string' ? error.message : 'Unknown process error';
|
||||
const withoutSecret = secret ? source.split(secret).join('[REDACTED]') : source;
|
||||
return withoutSecret.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 512);
|
||||
}
|
||||
|
||||
function safeMetadata(entry, status = entry.status) {
|
||||
return {
|
||||
processId: entry.processId,
|
||||
deviceId: entry.deviceId,
|
||||
startedAt: entry.startedAt,
|
||||
status
|
||||
};
|
||||
}
|
||||
|
||||
function createProxyManager({
|
||||
appDirectory,
|
||||
fs = nodeFs,
|
||||
spawn = nodeSpawn,
|
||||
platform = process.platform,
|
||||
now = Date.now
|
||||
} = {}) {
|
||||
if (platform !== 'win32') {
|
||||
throw new ProxyLaunchError('Proxy helper is supported only on the Windows platform.', 'UNSUPPORTED_PLATFORM');
|
||||
}
|
||||
if (typeof appDirectory !== 'string' || !nodePath.win32.isAbsolute(appDirectory)) {
|
||||
throw new ProxyLaunchError('Approved application directory must be an absolute path.', 'INVALID_APP_DIRECTORY');
|
||||
}
|
||||
if (typeof fs.existsSync !== 'function' || typeof spawn !== 'function' || typeof now !== 'function') {
|
||||
throw new TypeError('Invalid proxy manager dependency.');
|
||||
}
|
||||
|
||||
const helperPath = nodePath.win32.join(appDirectory, HELPER_FILENAME);
|
||||
const trackedChildren = new Map();
|
||||
|
||||
function removeIfOwned(processId, child) {
|
||||
const current = trackedChildren.get(processId);
|
||||
if (current && current.child === child) trackedChildren.delete(processId);
|
||||
}
|
||||
|
||||
function launchProxy(request) {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
throw new ProxyLaunchError('Proxy launch request is invalid.', 'INVALID_REQUEST');
|
||||
}
|
||||
|
||||
const deploymentHost = validateDeploymentHost(request.deploymentHost);
|
||||
const deviceId = validateDeviceId(request.deviceId);
|
||||
const cookie = validateCookie(request.cookie);
|
||||
|
||||
if (!fs.existsSync(helperPath)) {
|
||||
throw new ProxyLaunchError('Proxy helper was not found in the approved application directory.', 'HELPER_NOT_FOUND');
|
||||
}
|
||||
|
||||
let child;
|
||||
try {
|
||||
child = spawn(
|
||||
helperPath,
|
||||
['-a', deploymentHost, '-d', deviceId, '-k', cookie],
|
||||
{
|
||||
shell: false,
|
||||
detached: false,
|
||||
stdio: 'ignore',
|
||||
windowsHide: false
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
throw new ProxyLaunchError(
|
||||
`Failed to launch proxy helper: ${redactMessage(error, cookie)}`,
|
||||
'SPAWN_FAILED'
|
||||
);
|
||||
}
|
||||
|
||||
if (!child || !Number.isSafeInteger(child.pid) || child.pid <= 0 || typeof child.kill !== 'function') {
|
||||
throw new ProxyLaunchError('Proxy helper did not return a valid child process.', 'INVALID_CHILD_PROCESS');
|
||||
}
|
||||
if (trackedChildren.has(child.pid)) {
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
// The new child is deliberately not tracked when its PID collides.
|
||||
}
|
||||
throw new ProxyLaunchError('Proxy helper returned a process identifier already in use.', 'DUPLICATE_PROCESS_ID');
|
||||
}
|
||||
|
||||
const entry = {
|
||||
child,
|
||||
processId: child.pid,
|
||||
deviceId,
|
||||
startedAt: now(),
|
||||
status: 'running'
|
||||
};
|
||||
trackedChildren.set(entry.processId, entry);
|
||||
|
||||
if (typeof child.once === 'function') {
|
||||
child.once('exit', () => removeIfOwned(entry.processId, child));
|
||||
child.once('error', () => removeIfOwned(entry.processId, child));
|
||||
}
|
||||
|
||||
return { success: true, ...safeMetadata(entry) };
|
||||
}
|
||||
|
||||
function stopProxy(processId) {
|
||||
if (!Number.isSafeInteger(processId) || processId <= 0) {
|
||||
throw new ProxyLaunchError('Process identifier is invalid.', 'INVALID_PROCESS_ID');
|
||||
}
|
||||
|
||||
const entry = trackedChildren.get(processId);
|
||||
if (!entry) return { success: false, processId, status: 'not-tracked' };
|
||||
|
||||
try {
|
||||
const requested = entry.child.kill('SIGTERM');
|
||||
if (!requested) {
|
||||
removeIfOwned(processId, entry.child);
|
||||
return {
|
||||
success: true,
|
||||
processId,
|
||||
deviceId: entry.deviceId,
|
||||
status: 'already-exited'
|
||||
};
|
||||
}
|
||||
|
||||
removeIfOwned(processId, entry.child);
|
||||
return {
|
||||
success: true,
|
||||
processId,
|
||||
deviceId: entry.deviceId,
|
||||
status: 'stop-requested'
|
||||
};
|
||||
} catch (error) {
|
||||
const permissionDenied = error && (error.code === 'EPERM' || error.code === 'EACCES');
|
||||
return {
|
||||
success: false,
|
||||
processId,
|
||||
deviceId: entry.deviceId,
|
||||
status: permissionDenied ? 'permission-denied' : 'stop-failed',
|
||||
message: permissionDenied
|
||||
? 'Unable to stop the tracked proxy process: permission denied.'
|
||||
: 'Unable to stop the tracked proxy process.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function listTrackedProxies() {
|
||||
return Array.from(trackedChildren.values(), (entry) => safeMetadata(entry));
|
||||
}
|
||||
|
||||
return Object.freeze({ launchProxy, stopProxy, listTrackedProxies });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HELPER_FILENAME,
|
||||
MAX_COOKIE_LENGTH,
|
||||
ProxyLaunchError,
|
||||
createProxyManager,
|
||||
validateCookie,
|
||||
validateDeploymentHost,
|
||||
validateDeviceId
|
||||
};
|
||||
Reference in New Issue
Block a user