bbpro
Version:
BrowserBox - remote browser isolation tool
687 lines (640 loc) • 25 kB
JavaScript
import Connect from './connection.js';
import {getViewport} from './connection.js';
import {CONFIG,COMMAND_MAX_WAIT,throwAfter, untilTrue, sleep, throttle, DEBUG} from '../common.js';
import {MAX_FRAMES, MIN_TIME_BETWEEN_SHOTS, ACK_COUNT, MAX_ROUNDTRIP, MIN_SPOT_ROUNDTRIP, MIN_ROUNDTRIP, BUF_SEND_TIMEOUT, RACE_SAMPLE} from './screenShots.js';
import fs from 'fs';
const FRAME_GC_LIMIT = 5;
const TIME_WINDOW = 2;
const MAX_CONNECTIONS = process.env.MAX_CONN ? parseInt(process.env.MAX_CONN) : 5;
console.log({MAX_CONNECTIONS});
const connections = new Map();
// Connection options
const Options = {
adBlock: DEBUG.adBlock,
demoBlock: false
};
//const TAIL_START = 100;
//let lastTailShot = false;
//let lastHash;
let BANDWIDTH_ISSUE_STATE = false;
const goLowRes = throttle((connection, ...args) => connection.shrinkImagery(...args), 8000);
const goHighRes = throttle((connection, ...args) => connection.growImagery(...args), 8000);
const notifyBandwidthIssue = throttle(function (zombie_port, bandwidthIssue) {
DEBUG.debugAdaptiveImagery && console.log('Maybe notifying bwissue on ack', {bandwidthIssue});
if ( bandwidthIssue != BANDWIDTH_ISSUE_STATE ) {
controller_api.notifyBandwidthIssue(zombie_port, {issue: bandwidthIssue});
BANDWIDTH_ISSUE_STATE = bandwidthIssue;
DEBUG.debugAdaptiveImagery && console.log('Notified BW Issue');
}
}, 1000);
const controller_api = {
zombieIsDead(port) {
const connection = connections.get(port);
try {
return connection.zombie.disconnected;
} catch(e) {
console.warn('zombie dead', e);
return true;
}
},
sendFavicons(send, port) {
const connection = connections.get(port);
if ( connection?.favicons?.size ) {
const meta = [...connection.favicons.entries()].map(([targetId, faviconDataUrl]) => ({favicon:{targetId, faviconDataUrl}}));
if ( connection.modal ) {
const {modal} = connection;
connection.forceMeta({modal});
}
DEBUG.debugFaviconsSend && console.log(`Will send favicons`, meta);
send({meta});
}
},
fanOut(deliverTo, port) {
const connection = connections.get(port);
if ( connection ) {
for( const {socket} of connection.links.values() ) {
deliverTo(socket);
}
} else {
throw new TypeError(`No connection on port ${port}`);
}
},
linkStats(port) {
const connection = connections.get(port);
if ( connection ) {
return {
onlineCount: connection.links.size
}
} else {
throw new TypeError(`No connection on port ${port}`);
}
},
async screenshotAck(connectionId, port, receivedFrameId, channel) {
const {frameId, castSessionId} = receivedFrameId;
const connection = connections.get(port);
let bandwidthIssue = false;
//DEBUG.debugCast && console.log('Acking', connectionId, port, receivedFrameId);
if ( connection ) {
const channels = connection.links.get(connectionId);
if ( ! channels ) {
throw new TypeError(`No client connected with connectionId ${connectionId}`);
}
const {ack} = channels;
if ( receivedFrameId.requiresCastId ) {
receivedFrameId.requiresCastId = undefined;
// TODO: update this to ensure correct cast session for target
receivedFrameId.castSessionId = connection.latestCastId;
}
try {
ack.received = receivedFrameId;
ack.count = ACK_COUNT;
ack.receivedAt = Date.now();
if ( CONFIG.screencastOnly ) {
if ( ! connection.sessionId ) {
DEBUG.showNoSessionIdWarnings && console.warn(`No sessionId for screencast ack`);
}
controller_api.send({
name: "Page.screencastFrameAck",
params: {
sessionId: castSessionId || 1
},
sessionId: connection.sessionId
}, port).then(sendResult => {
DEBUG.metaDebug && console.log({sendResult});
if ( channel ) {
const {Data, Frames, Meta, State, receivesFrames, messageId} = channel;
const {data, meta, frameBuffer, totalBandwidth} = sendResult;
if ( data ) {
Data.push(data);
}
if ( meta ) {
Meta.push(...meta);
}
if ( frameBuffer ) {
Frames.push(...frameBuffer);
while ( Frames.length > MAX_FRAMES ) {
Frames.shift();
}
}
State.totalBandwidth = totalBandwidth;
} else {
DEBUG.channelDebug && console.warn(`Got a send result but no channel to set results upstream. Uh OH`);
DEBUG.channelDebug && console.log((new Error).stack);
}
})
const castInfo = connection.currentCast;
if ( castInfo ) {
DEBUG.debugAckBlast && console.log(`Setting session has received frame`);
castInfo.sessionHasReceivedFrame = true;
}
DEBUG.debugCast && console.log("Sent ack");
}
if ( DEBUG.adaptiveImagery ) {
const sentAt = ack.sent.get(frameId);
if ( sentAt ) {
ack.sent.delete(frameId);
const roundtripTime = (ack.receivedAt - sentAt);
DEBUG.debugAdaptiveImagery && console.log({rtt:roundtripTime});
ack.times.push(roundtripTime);
ack.timeSum += roundtripTime;
while(ack.times.length > TIME_WINDOW) {
ack.timeSum -= ack.times.shift();
}
const avgRoundtrip = ack.timeSum / ack.times.length;
DEBUG.debugAdaptiveImagery && console.log(`Average roundtrip time: ${avgRoundtrip}ms, actual: ${roundtripTime}ms`);
if ( avgRoundtrip > MAX_ROUNDTRIP /*|| roundtripTime > MAX_ROUNDTRIP */ ) {
bandwidthIssue = true;
goLowRes(connection);
} else if ( avgRoundtrip < MIN_ROUNDTRIP /*|| roundtripTime < MIN_SPOT_ROUNDTRIP */) {
bandwidthIssue = false;
goHighRes(connection);
}
}
}
if ( !ack.sending ) {
//DEBUG.debugCast && console.log("Sending frames", ack);
ack.sending = true;
await sleep(10);
while ( ack.count && DEBUG.bufSend && ack.bufSend && ack.buffer.length ) {
(DEBUG.debugCast || DEBUG.acks) && console.log(`Got ack from ${connectionId} and have buffered unsent frame. Will send now.`);
const {peer, socket, fastest} = channels;
const channel = DEBUG.chooseFastest && fastest ? fastest :
DEBUG.useWebRTC && peer ? peer : socket;
const [imgBuf, frameId] = ack.buffer.pop();
DEBUG.shotDebug && console.log('Sending', frameId);
connection.so(channel, imgBuf);
DEBUG.adaptiveImagery && ack.sent.set(frameId, Date.now());
if ( DEBUG.chooseFastest && DEBUG.useWebRTC && socket && peer ) {
const choice = Math.random() >= RACE_SAMPLE;
if ( choice ) {
const otherChannel = channel === peer ? socket : peer;
connection.so(otherChannel, imgBuf);
DEBUG.logFastest && console.log('Race started');
}
}
//ack.bufSend = false;
ack.received = 0;
ack.count -= 1;
if ( ack.count < 0 ) {
ack.count = 0;
ack.buffer.length = 0;
}
await sleep(MIN_TIME_BETWEEN_SHOTS);
}
ack.sending = false;
if ( ack.sent.size > FRAME_GC_LIMIT ) {
for( const key of ack.sent.keys() ) {
if ( key < frameId ) ack.sent.delete(key);
}
}
DEBUG.acks && console.log(`Set ack received ${connectionId}`);
}
} catch(e) {
console.warn('screenshotAck error', e);
ack.sending = false;
}
notifyBandwidthIssue(port, bandwidthIssue);
} else {
throw new TypeError(`No connection on port ${port}`);
}
},
async addLink({so, forceMeta}, {connectionId, peer, socket, fastest}, port) {
await untilTrue(() => connections.has(port), 100, 1000);
const connection = connections.get(port);
if ( connection ) {
let channels = connection.links.get(connectionId);
if ( (socket || peer) && ! channels ) {
DEBUG.val && console.log("Links", connection.links.size, "Max", MAX_CONNECTIONS);
if ( ( connection.links.size + 1) > MAX_CONNECTIONS ) {
console.warn('Closing connection as total connection count exceeds MAX_CONNECTIONS');
if ( socket ) {
so(socket,
{
data: [{
error: `There are already ${MAX_CONNECTIONS} in the session. The room is full`
}]
}
);
setTimeout(() => socket && socket.close(), 600);
}
if ( peer ) {
setTimeout(() => peer && peer.destroy(), 500);
}
return;
}
channels = {ack:{received:true, buffer:[], sent:new Map(), times: [], timeSum: 0, bufSend: true}};
DEBUG.acks && console.log(`set ack received true at first ${connectionId}`, channels);
(DEBUG.socDebug || DEBUG.debugConnect) && console.info(`Add connection ${connectionId}`);
connection.links.set(connectionId, channels);
untilTrue(() => connection.sessionId, 300, 100).then(() => {
DEBUG.acks && console.log(
'sending ack on link add or change',
connection.sessionId, connection.latestCastId
);
controller_api.screenshotAck(connectionId, port, 1);
});
}
if ( ! peer && ! socket ) {
(DEBUG.socDebug || DEBUG.debugConnect) && console.info(`Disconnect. Remove connection ${connectionId}`);
this.deleteLink({connectionId}, port);
} else if ( channels ) {
channels.peer = peer;
channels.socket = socket;
if ( channels.fastest !== fastest ) {
DEBUG.logFastest && console.log(`Switched fastest channel to ${
fastest === peer ? 'webrtc peer' : 'websocket'
}.`);
channels.fastest = fastest;
}
DEBUG.socDebug && console.log(`Client ${connectionId} setting ${peer ? 'peer' : ''} ${peer && socket? 'and socket' : 'socket'}`);
if ( !fastest && peer && socket ) {
console.log("Links keys", ...connection.links.keys());
}
}
if ( ! connection.so ) {
connection.so = so;
}
if ( ! connection.forceMeta ) {
connection.forceMeta = forceMeta;
}
connection.doShot({forceFrame:true});
} else {
console.info(connections);
throw new TypeError(`No connection on port ${port}`);
}
},
deleteLink({connectionId}, port) {
const connection = connections.get(port);
(DEBUG.socDebug || DEBUG.debugConnect) && console.info(`Remove connection ${connectionId}`);
if ( connection ) {
connection.links.delete(connectionId);
connection.viewports.delete(connectionId);
const remainingConnectionId = [...connection.viewports.keys()][0];
DEBUG.debugViewportDimensions && console.log('Viewports--', connection.viewports);
const viewport = getViewport(...connection.viewports.values());
DEBUG.debugViewportDimensions && console.log('Common viewport', viewport);
DEBUG.coords && console.log(`Resetting device metrics on client departure`, viewport);
this.send({
name: "Browser.setWindowBounds",
params: {
windowId: connection.latestWindowId,
bounds: viewport,
},
requiresWindowId: true,
forceFrame: true,
receivesFrames: true,
}, port);
this.send({
name: "Emulation.setDeviceMetricsOverride",
params: {
...viewport,
mobile: viewport.mobile ? viewport.mobile : connection.isMobile,
},
receivesFrames: true,
requiresShot: true,
forceFrame: true,
}, port);
} else {
throw new TypeError(`No connection on port ${port}`);
}
},
setClientErrorSender(sender, port) {
const connection = connections.get(port);
if ( ! connection ) {
return false;
}
connection.setClientErrorSender(sender);
return true;
},
setOptions(new_options) {
Object.assign(Options, new_options);
},
async close(port) {
const connection = connections.get(port);
if ( ! connection ) {
return true;
}
connections.delete(port);
return await connection.close();
},
getActiveTarget(port) {
const connection = connections.get(port);
if ( ! connection ) return;
const activeTargetId = connection.sessions.get(connection.sessionId);
return activeTargetId;
},
getHiddenTarget(port) {
const connection = connections.get(port);
if ( ! connection ) return;
return connection.hiddenTargetId;
},
setHiddenTarget(targetId, port) {
const connection = connections.get(port);
if ( ! connection ) return;
connection.hiddenTargetId = targetId;
},
addTargets(tabs, port) {
if ( ! tabs ) throw new TypeError(`No tabs provided.`);
const connection = connections.get(port);
if ( ! connection ) return;
tabs.forEach(({targetId}) => {
connection.targets.add(targetId);
});
},
hasTab(targetId, port) {
const connection = connections.get(port);
if ( ! connection ) return false;
return connection.tabs.has(targetId);
},
hasSession(targetId, port) {
const connection = connections.get(port);
if ( ! connection ) return false;
return connection.sessions.has(targetId);
},
getBrowserTargetId(port) {
const connection = connections.get(port);
if ( ! connection ) return;
return connection.browserTargetId;
},
async send(command, port, Saver) {
let retVal = {};
let connection = connections.get(port);
//let Page, Target;
try {
if ( ! connection ) {
connection = await Connect({port}, Options);
connections.set(port,connection)
}
//({Page, Target} = connection.zombie);
command = command || {};
DEBUG.val >= DEBUG.high && !command.isBufferedResultsCollectionOnly && console.log(JSON.stringify(command));
if ( command.isBufferedResultsCollectionOnly ) {
DEBUG.frameDebug && process.stdout.write('.');
retVal.data = {};
} else if ( command.isZombieLordCommand ) {
switch(command.name) {
// ALTERNATE : we COULD change this API to NOT reveal contextIds to client.
// and instead offer a command like Connection.broadcastToAllContextsInSession()
// that takes a script to evaluate
case "Connection.screenshotAck": {
throw new TypeError(`We don't handle ack here.`);
}
break;
case "Connection.doShot": {
DEBUG.val && console.log("Calling do shot");
connection.doShot({forceFrame:true});
}
break;
case "Connection.getFavicon": {
const targetId = command.params.targetId || connection.sessions.get(connection.sessionId);
const faviconDataUrl = connection.favicons.get(targetId);
DEBUG.debugFavicon && console.log('Received message to Get Favicon',
{targetId, faviconDataUrl}, connection.favicons, command, connection.sessions);
if ( faviconDataUrl ) {
connection.forceMeta({
favicon: {
targetId,
faviconDataUrl
}
});
}
}
break;
case "Connection.getContextIdsForActiveSession": {
DEBUG.showContextIdCalls && console.log('Received', command);
const contexts = connection.worlds.get(connection.sessionId);
const targetId = connection.sessions.get(connection.sessionId);
// console.log(connection.worlds, connection.sessions, contexts,targetId,connection.sessionId);
if ( ! contexts ) {
retVal.data = {contextIds:[]};
} else {
DEBUG.val > DEBUG.med && console.log({currentSession:connection.sessionId, targetId, contexts:[...contexts.values()]});
retVal.data = {contextIds:[...contexts.values()]};
}
DEBUG.showContextIdCalls && console.log('Ret val', retVal);
}
break;
case "Connection.getAllContextIds": {
const allContexts = [];
for ( const sessionId of connection.worlds.keys() ) {
// because we double book entry in connection worlds both sessionId and targetId
// so we have to check if the key is targetId and if so we skip
if ( connection.targets.has(sessionId) ) continue;
const contexts = connection.worlds.get(sessionId);
if ( contexts ) {
for ( const contextId of contexts ) {
allContexts.push({sessionId,contextId});
}
}
}
retVal.data = {sessionContextIdPairs:allContexts};
}
break;
case "Connection.getAllSessionIds": {
const sessionIds = [];
for ( const sessionId of connection.worlds.keys() ) {
// because we double book entry in connection worlds both sessionId and targetId
// so we have to check if the key is targetId and if so we skip
if ( connection.targets.has(sessionId) ) continue;
sessionIds.push(sessionId);
}
retVal.data = {sessionIds};
}
break;
case "Connection.setIsFirefox": {
connection.isFirefox = true;
}
break;
case "Connection.setIsSafari": {
connection.isSafari = true;
}
break;
case "Connection.setIsMobile": {
connection.isMobile = true;
}
break;
case "Connection.getTabs": {
const tabs = Array.from(connection.tabs.values());
retVal.data = {tabs};
}
break;
case "Connection.enableMode": {
// reset first
const plugins = Object.keys(connection.plugins);
for ( const pluginName of plugins ) {
connection.plugins[pluginName] = false;
}
// now enable
const {pluginName} = command.params;
if ( pluginName ) {
connection.plugins[pluginName] = true;
}
retVal.data = {};
}
break;
case "Connection.resetMode": {
const plugins = Object.keys(connection.plugins);
for ( const pluginName of plugins ) {
connection.plugins[pluginName] = false;
}
retVal.data = {};
}
case "Connection.resampleImagery": {
const {down, up, averageBw} = command.params;
if ( DEBUG.adaptiveImagery ) {
if ( down ) {
goLowRes(connection, {averageBw});
} else if ( up ) {
goHighRes(connection, {averageBw});
}
}
}
break;
case "Connection.closeModal": {
connection.forceMeta({
closeModal: command.params
});
DEBUG.val && console.log(`received close modal and sent to clients`, command);
}
break;
case "Connection.activateTarget": {
connection.forceMeta({
activateTarget: command.params
});
DEBUG.val && console.log(`received activateTarget and sent to clients`, command);
}
break;
default: {
console.warn(`Unknown zombie lord command: ${command.name}`);
}
break;
}
} else if ( command.name ) {
DEBUG.val > DEBUG.med && console.log({command});
DEBUG.pausedDebug && console.log({paused:connection.vmPaused});
if ( /* connection.vmPaused || */ command.dontWait ) {
connection.sessionSend(command).then(resp => {
DEBUG.debugCommandResponses && console.log(`DROPPED RESPONSE`, resp);
return resp;
});
retVal.data = {vmPaused: connection.vmPaused};
DEBUG.debugModals && console.log("Saving VM paused", retVal);
} else {
const CancelTimer = {};
const response = await Promise.race([
connection.sessionSend(command),
throwAfter(COMMAND_MAX_WAIT, command, port, CancelTimer)
]);
DEBUG.dataDebug && console.log({response});
CancelTimer.do();
/**
console.log(
"XCHK controller.js call response", command, response, CancelTimer.do.toString()
);
**/
if ( response?.meta ) {
connection.forceMeta(...response.meta);
} else if ( response ) {
retVal.data = response;
} else {
DEBUG.debugCommandResponses && console.warn(`No response to command`, command);
}
}
}
if ( command.requiresLoad ) {
// we could do a promise race here load vs sleep
//await Page.loadEventFired();
await sleep();
}
if ( command.requiresExtraWait ) {
await sleep(command.extraWait);
}
if ( command.requiresShot || command.forceFrame ) {
DEBUG.frameDebug && console.log({forceFrame:{command}});
await connection.doShot({ignoreHash: command.ignoreHash || command.forceFrame, blockExempt: command.name == "Target.activateTarget"});
}
if ( command.requiresTailShot ) {
connection.queueTailShot({ignoreHash: command.ignoreHash});
}
if ( connection.frameBuffer.length && command.receivesFrames ) {
retVal.frameBuffer = Array.from(connection.frameBuffer);
connection.frameBuffer.length = 0;
if ( !DEBUG.binaryFrames ) {
retVal.frameBuffer = retVal.frameBuffer.filter(frame => {
if ( frame.hash == connection.lastHash ) {
DEBUG.shotDebug && DEBUG.val > DEBUG.med && console.log(`DROP frame ${frame.hash}`);
return false;
} else {
connection.lastHash = frame.hash;
DEBUG.shotDebug && DEBUG.val > DEBUG.med && console.log(`SEND frame ${frame.hash}`);
return true;
}
});
}
DEBUG.val > DEBUG.med && console.log(`Sending ${retVal.frameBuffer.length} frames.`);
}
if ( connection.meta.length ) {
(DEBUG.metaDebug || DEBUG.frameDebug) && console.log('Meta length', connection.meta.length);
retVal.meta = Array.from(connection.meta);
(DEBUG.metaDebug || DEBUG.debugFavicon) && console.warn(`META: Resetting meta!`, JSON.stringify(connection.meta));
connection.meta.length = 0;
//move([], connection.meta);
(DEBUG.metaDebug || (DEBUG.val > DEBUG.med)) && console.log(
`Sending ${retVal.meta.length} meta.`
);
}
retVal.totalBandwidth = connection.totalBandwidth;
} catch(e) {
console.warn(e);
retVal.data = {error:e+'', resetRequired: true};
}
DEBUG.debugModals && console.log("VM PAUSED", connection.vmPaused, "MODAL", connection.modal);
if ( connection.vmPaused ) {
if ( !retVal.data ) {
retVal.data = {};
}
retVal.data.vmPaused = connection.vmPaused;
retVal.data.modal = connection.modal;
}
return retVal;
},
notifyBandwidthIssue(port, {issue}) {
const connection = connections.get(port);
if ( connection ) {
connection.forceMeta({bandwidthIssue: issue ? 'yes' : 'no'});
}
},
saveIP(ip_address) {
this.ip_address = ip_address;
console.log("Connect from", ip_address);
},
logIP() {
try {
fs.appendFileSync("/tmp/badIPLog", this.ip_address + "\n");
} catch(e) {
console.warn("Error appending log", e);
try {
fs.writeFileSync("/tmp/badIPLog", this.ip_address + "\n");
} catch(e2) {
console.warn("Error writing log", e);
}
}
},
isSafari(port) {
const connection = connections.get(port);
if ( ! connection ) {
throw new TypeError(`No such connection on port: ${port}`);
}
return connection.isSafari;
},
getConnection(port) {
return connections.get(port);
}
};
export default controller_api;
function move(a_dest, a_src) {
while(a_src.length) {
a_dest.push(a_src.shift());
}
return a_dest;
}