UNPKG

bbpro

Version:

BrowserBox - remote browser isolation tool

1,202 lines (1,133 loc) 44.7 kB
import http from 'http'; import https from 'https'; import fs from 'fs'; import os from 'os'; import path from 'path'; import child_process from 'child_process'; import express from 'express'; /* import fetch from 'node-fetch'; */ import multer from 'multer'; import {WebSocketServer, WebSocket} from 'ws'; import Peer from 'simple-peer'; import WRTC from '@roamhq/wrtc'; import bodyParser from 'body-parser'; import cookieParser from 'cookie-parser'; import cors from 'cors'; import helmet from 'helmet'; import rateLimit from 'express-rate-limit'; import csrf from 'csurf'; //import {CWebp} from 'cwebp'; import zl from './zombie-lord/api.js'; import {start_mode} from './args.js'; import { T2_MINUTES, version, APP_ROOT, COOKIENAME, GO_SECURE, DEBUG, CONFIG, ALLOWED_3RD_PARTY_EMBEDDERS, throttle, } from './common.js'; import {timedSend, eventSendLoop} from './server.js'; import {MIN_TIME_BETWEEN_SHOTS, WEBP_QUAL} from './zombie-lord/screenShots.js'; // config const SafariPlatform = /^((?!chrome|android).)*safari/i; const PEER_RECONNECT_MS = 2000; const FRAME_LIMIT = false; // 'SAMEORIGIN' or 'DENY' or false (no limit) const protocol = GO_SECURE ? https : http; const COOKIE_OPTS = { secure: GO_SECURE, httpOnly: true, maxAge: 345600000, }; if ( GO_SECURE ) { // same site only allowed with secure COOKIE_OPTS.sameSite = ALLOWED_3RD_PARTY_EMBEDDERS.length > 0 ? 'None' : 'Strict'; } Object.freeze(COOKIE_OPTS); DEBUG.debugCookie && console.log(COOKIE_OPTS); // file uploads export const fileChoosers = new Map(); const uploadPath = path.resolve(CONFIG.baseDir, 'uploads'); if ( ! fs.existsSync(uploadPath) ) { fs.mkdirSync(uploadPath, {recursive:true}); } const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, uploadPath), filename: (req, file, cb) => { return cb(null, nextFileName(path.extname(file.originalname))) } }); const upload = multer({storage}); // message queue const Sent = new Map(); const Queue = { funcs: [] }; // selective Cors const Cors = cors(); const CrossOriginOpen = helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' }, contentSecurityPolicy: { directives: { objectSrc: [ "'self'" ] } }, }); // rate limiter const RateLimiter = rateLimit({ windowMs: 1000 * 60 * 3, max: DEBUG.mode == 'dev' ? 10000 : 2000, message: ` Too many requests from this IP. Please try again in a little while. ` }); const ConstrainedRateLimiter = rateLimit({ windowMs: 10000, max: 1, message: ` Too many requests from this IP. Please try again in a little while. ` }); // Safari special cookie loader let SAFARI_PERMISSION_LOADER_HTML = `<h1>Safari not supported...</h1>`; const SafariPermissionLoader = () => DEBUG.loadSPLFreshEachLogin ? SAFARI_PERMISSION_LOADER_HTML = fs.readFileSync(path.resolve(APP_ROOT,'assets','SPL.html')) : SAFARI_PERMISSION_LOADER_HTML ; // Integrity check const integrityFilePath = path.resolve(os.homedir(), 'BBPRO.INTEGRITY'); const INTEGRITY_FILE_CONTENT = fs.existsSync(integrityFilePath) ? fs.readFileSync(integrityFilePath).toString('utf8') : 'null-integrity-check'; DEBUG.debugIntegrity && console.log({integrityFileContent: INTEGRITY_FILE_CONTENT}); // keep tabs organized const TabNumbers = new Map(); export let LatestCSRFToken = ''; let serverOrigin; let messageQueueRunning = false; let requestId = 0; let TabNumber = 0; export async function start_ws_server( port, zombie_port, allowed_user_cookie, session_token, ) { if ( DEBUG.debugAddr ) { const base = port - 2; for( let i = base; i < base + 5; i++ ) { const key = `ADDR_${i}`; console.log(key, process.env[key]); } } DEBUG.val && console.log(`Starting websocket server on ${port}`); const app = express(); const server_port = parseInt(port); const StandardCSP = { defaultSrc: ["'self'"], imgSrc: [ "'self'", "data:", "blob:" ], mediaSrc: [ "data:", "'self'", "https://localhost:*", "https://*.dosyago.com:*", "https://*.browserbox.pro:*", ...(process.env.TORBB ? [ `https://${process.env[`ADDR_${server_port}`]}:*`, // main service (for data: urls seemingly) `https://${process.env[`ADDR_${server_port - 2}`]}:*`, // audio onion service ] : []) ], frameSrc: [ "'self'", "https://localhost:*", "https://*.browserbox.pro:*", "https://*.dosyago.com:*", ...(process.env.TORBB ? [ `https://${process.env[`ADDR_${server_port - 2}`]}:*`, // audio onion service ] : []) ], connectSrc: [ "'self'", "wss://*.dosyago.com:*", "wss://localhost:*", `https://*.dosyago.com:${server_port-1}`, `https://*.dosyago.com:${server_port+1}`, "https://*.browserbox.pro:*", "wss://*.browserbox.pro:*", `https://localhost:${server_port-1}`, `https://localhost:${server_port+1}`, ...CONFIG.connectivityTests, ...(process.env.TORBB ? [ `https://${process.env[`ADDR_${server_port}`]}:*`, // main service `https://${process.env[`ADDR_${server_port - 2}`]}:*`, // audio onion service `https://${process.env[`ADDR_${server_port + 1}`]}:*`, // devtools `https://${process.env[`ADDR_${server_port + 2}`]}:*`, // docs ] : []) ], fontSrc: [ "'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://fonts.gstatic.com" ], styleSrc: [ "'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://fonts.gstatic.com" ], scriptSrc: [ "'self'", "'unsafe-eval'", "'unsafe-inline'", "https://*.browserbox.pro:*", "https://*.dosyago.com:*" ], scriptSrcAttr: [ "'self'", "'unsafe-inline'" ], frameAncestors: [ "'self'", "https://*.browserbox.pro:*", "https://*.dosyago.com:*", ...ALLOWED_3RD_PARTY_EMBEDDERS ], objectSrc: ["'none'"], upgradeInsecureRequests: [], }; const sockets = new Set(); const websockets = new Set(); const peers = new Set(); let latestMessageId = 0; if ( ! DEBUG.noSecurityHeaders ) { app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], imgSrc: [ "'self'", "data:", "blob:" ], mediaSrc: [ "'self'", "https://*.dosyago.com:*" ], frameSrc: [ "'self'", "https://*.dosyago.com:*" ], connectSrc: [ "'self'", "wss://*.dosyago.com:*", "wss://localhost:*", `https://*.dosyago.com:${server_port-1}`, `https://*.dosyago.com:${server_port+1}`, `https://localhost:${server_port-1}`, `https://localhost:${server_port+1}`, ...CONFIG.connectivityTests ], fontSrc: [ "'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://fonts.gstatic.com" ], styleSrc: [ "'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://fonts.gstatic.com" ], scriptSrc: [ "'self'", "'unsafe-eval'", "'unsafe-inline'", "https://*.dosyago.com:*" ], scriptSrcAttr: [ "'self'", "'unsafe-inline'" ], frameAncestors: [ "'self'", "https://*.dosyago.com:*", ...ALLOWED_3RD_PARTY_EMBEDDERS ], objectSrc: ["'none'"], upgradeInsecureRequests: [], }, reportOnly: false, }, frameguard: FRAME_LIMIT })); app.use(helmet({ contentSecurityPolicy: { directives: StandardCSP, reportOnly: false, }, frameguard: FRAME_LIMIT })); } const CrossOriginSecure = helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' }, contentSecurityPolicy: { directives: StandardCSP, reportOnly: false, }, frameguard: FRAME_LIMIT }); const SEC_HEADERS = DEBUG.noSecurityHeaders ? [Cors] : [Cors, CrossOriginSecure]; const OPEN_HEADERS = [Cors, CrossOriginOpen]; app.use(RateLimiter); app.use(bodyParser.urlencoded({extended:true})); app.use(bodyParser.json()); app.use(cookieParser()); app.use(upload.array("files", 10)); app.use(csrf({cookie: {sameSite: 'None', secure:true}})); app.use((req, res, next) => { const newOrigin = `${req.protocol}://${req.headers.host}`; if ( newOrigin !== serverOrigin ) { serverOrigin = newOrigin; DEBUG.showOrigin && console.log({serverOrigin}); } LatestCSRFToken = req.csrfToken(); next(); }); // serve assets that can be injected into pages app.get('/assets/*', OPEN_HEADERS, (req, res, next) => next()); if ( start_mode == "signup" ) { app.get("/", (req,res) => res.sendFile(path.join(APP_ROOT, 'public', 'index.html'))); } else { if ( DEBUG.mode == 'dev' ) { app.get("/", SEC_HEADERS, (req,res) => res.sendFile(path.join(APP_ROOT, 'public', 'image.html'))); } else { app.get("/", SEC_HEADERS, (req,res) => res.sendFile(path.join(APP_ROOT, '..', 'dist', 'image.html'))); } app.get("/login", SEC_HEADERS, (req,res) => { console.log(req.query); const {ui: ui = 'true',token,ran: ran = Math.random(),url:Url} = req.query; DEBUG.debugCookie && console.log({token, session_token}); if ( token == session_token ) { res.cookie(COOKIENAME+port, allowed_user_cookie, COOKIE_OPTS); DEBUG.debugCookie && console.log('set cookie', COOKIENAME+port, allowed_user_cookie, COOKIE_OPTS); let url; url = `/?ran=${ran||Math.random()}&ui=${ui}#${session_token}`; if ( process.env.TORBB ) { const zVal = encodeURIComponent(btoa(JSON.stringify({ x: process.env[`ADDR_${server_port-2}`], // audio port onion service y: process.env[`ADDR_${server_port+1}`], // devtools port onion service }))); if ( !! Url ) { url = `/?url=${encodeURIComponent(Url)}&z=${zVal}&ran=${ran||Math.random()}&ui=${ui}#${session_token}`; } else { url = `/?ran=${ran||Math.random()}&z=${zVal}&ui=${ui}#${session_token}`; } } else if ( !! Url ) { url = `/?url=${encodeURIComponent(Url)}&ran=${ran||Math.random()}&ui=${ui}#${session_token}`; } console.log({url}); const userAgent = req.headers['user-agent']; const isSafari = SafariPlatform.test(userAgent); if ( isSafari ) { res.type('html'); res.end(SafariPermissionLoader()); } else { res.redirect(url); } } else { res.type("html"); if ( session_token == 'token2' ) { res.end(`Incorrect token, not token2. <a href=/login?token=token2>Try again.</a>`); } else { res.end(`Incorrect token. <a href=https://${req.hostname}/>Try again.</a>`); } } }); app.get("/SPLlogin", (req,res) => { const {token,ran,url:Url} = req.query; if ( token == session_token ) { res.cookie(COOKIENAME+port, allowed_user_cookie, COOKIE_OPTS); let url; url = `/?ran=${ran||Math.random()}#${session_token}`; if ( !! Url ) { url = `/?url=${encodeURIComponent(Url)}&ran=${ran||Math.random()}#${session_token}`; } res.redirect(url); } else { res.type("html"); if ( session_token == 'token2' ) { res.end(`Incorrect token, not token2. <a href=/login?token=token2>Try again.</a>`); } else { res.end(`Incorrect token. <a href=https://${req.hostname}/>Try again.</a>`); } } }); app.get("/pptr", (req,res) => { res.type('html'); res.end(` <!DOCTYPE html> <script src=pptr_login.js></script> `); }); app.get("/SPLgenerate", (req,res) => { res.cookie('SPL-cookie'+port, Date.now().toString(36), COOKIE_OPTS); res.sendFile(path.resolve(APP_ROOT,...(DEBUG.mode === 'dev' ? ['public', 'assets'] : ['..', 'dist', 'assets']),'SPL2.html')); }); } if ( process.env.TORBB ) { app.get("/torca/rootCA.pem", (req, res) => res.sendFile(path.resolve(process.env.TORCA_CERT_ROOT, 'rootCA.pem'))); } app.use(express.static(path.resolve(APP_ROOT, ...(DEBUG.mode === 'dev' ? ['public'] : ['..', 'dist'])))); try { SAFARI_PERMISSION_LOADER_HTML = fs.readFileSync(path.resolve(APP_ROOT,...(DEBUG.mode === 'dev' ? ['public', 'assets'] : ['..', 'dist', 'assets']),'SPL.html')); } catch(e) { console.warn(`Could not find SafariPermissionLoader HTML file. Hope you don't need to support Safari!`); } const secure_options = {}; try { const sec = { key: fs.readFileSync(path.resolve(os.homedir(), CONFIG.sslcerts(server_port), 'privkey.pem')), cert: fs.readFileSync(path.resolve(os.homedir(), CONFIG.sslcerts(server_port), 'fullchain.pem')), ca: fs.existsSync(path.resolve(os.homedir(), CONFIG.sslcerts(server_port), 'chain.pem')) ? fs.readFileSync(path.resolve(os.homedir(), CONFIG.sslcerts(server_port), 'chain.pem')) : undefined }; Object.assign(secure_options, sec); } catch(e) { console.warn(`No certs found so will use insecure no SSL.`); console.log(secure_options, CONFIG.sslcerts(server_port)); } const secure = secure_options.cert && secure_options.key; const server = protocol.createServer.apply(protocol, GO_SECURE && secure ? [secure_options, app] : [app]); const wss = new WebSocketServer({ server, perMessageDeflate: false }); let shuttingDown = false; const shutDown = (sig) => { console.log(`Shutdown requested. Signal: ${sig}`); if ( shuttingDown ) return; shuttingDown = true; server.close(() => console.info(`Server closed on SIGINT`)); peers.forEach(peer => { try { peer.destroy(`Server going down.`); } catch(e) { DEBUG.debugConnect && console.info(`Error closing peer`, e, peer); } }); websockets.forEach(ws => { try { ws.close(1001, "server going down"); } catch(e) { DEBUG.debugConnect && console.info(`Error closing websocket`, e, ws); } }); sockets.forEach(socket => { try { socket.destroy() } catch(e) { DEBUG.socDebug && console.warn(`MAIN SERVER: port ${server_port}, error closing socket`, e) } }); process.exit(0); }; server.on('connection', socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); }); server.on('upgrade', (req, socket, head) => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); socket.setNoDelay(true); }); wss.on('connection', async (ws, req) => { const connectionId = Math.random().toString(36) + (+ new Date).toString(36); const url = new URL(req.url, `${req.protocol}://${req.headers.host}`); const qp = url.searchParams.get('session_token'); const cookie = req.headers.cookie; const IP = req.connection.remoteAddress; let closed = false; DEBUG.val && console.log({wsConnected:{cookie, qp, IP}}); zl.act.saveIP(IP); const validAuth = (cookie && cookie.includes(`${COOKIENAME+port}=${allowed_user_cookie}`)) || (qp && qp == session_token); if( validAuth ) { DEBUG.debugConnect && console.log(`Check 1`); await zl.act.addLink({so, forceMeta}, {connectionId, fastest: null, peer: null, socket:ws}, zombie_port); DEBUG.debugConnect && console.log(`Check 2`); forceMeta({ multiplayer: { onlineCount: zl.act.linkStats(zombie_port).onlineCount } }); DEBUG.debugConnect && console.log(`Check 3`); let peer; if ( DEBUG.useWebRTC ) { connectPeer(); function connectPeer() { DEBUG.debugConnect && console.log(`Check 4`); peer = new Peer({ wrtc: WRTC, trickle: true, initiator: true, channelConfig: { ordered: true, maxRetransmits: 0, /*maxPacketLifeTime: MIN_TIME_BETWEEN_SHOTS*/ } }); DEBUG.debugConnect && console.log(`Check 5`); peer.on('error', err => { DEBUG.val && console.log('webrtc error', err); if ( peer ) { peers.delete(peer); peer = null; zl.act.addLink({so, forceMeta}, {connectionId, peer: null, socket:ws}, zombie_port); if ( ws?.readyState < WebSocket.CLOSING ) { setTimeout(connectPeer, PEER_RECONNECT_MS); } } }); peer.on('close', c => { DEBUG.val && console.log('peer closed', c); if ( peer ) { peers.delete(peer); peer = null; zl.act.addLink({so, forceMeta}, {connectionId, peer: null, socket:ws}, zombie_port); if ( ws?.readyState < WebSocket.CLOSING ) { setTimeout(connectPeer, PEER_RECONNECT_MS); } } }); peer.on('connect', () => { DEBUG.cnx && console.log('peer connected'); //setTimeout(() => zl.act.addLink({so, forceMeta}, {connectionId, peer, socket:ws}, zombie_port), 15000); zl.act.addLink({so, forceMeta}, {connectionId, peer, socket:ws}, zombie_port); }); peer.on('signal', data => { DEBUG.cnx && console.log(`have webrtc signal data. sending to client`, data); so(ws, {copeer:{signal:data}}); peers.add(peer); }); } } zl.life.onDeath(zombie_port, () => { console.info("Zombie/chrome closed or crashed."); //console.log("Closing as zombie crashed."); //ws.close(); }); ws.on('close', () => { DEBUG.debugConnect && console.log(`Check 8`); websockets.delete(ws); closed = true; ws = null; peer && peer.destroy(new Error(`Main communication WebSocket closed.`)); peer = null; zl.act.addLink({so, forceMeta}, {connectionId, peer: null, socket:null}, zombie_port); zl.act.deleteLink({connectionId}, zombie_port); zl.act.fanOut(socket => so( socket, { meta:[ { multiplayer: { onlineCount: zl.act.linkStats(zombie_port).onlineCount } } ] } ), zombie_port); }); ws.on('message', message => { // possible improvement to simplicity and efficiency // creating a function for EVERY message call is probably really inefficient // I know my servers run low CPU and low memory but this might be better somehow // still I prioritize simplicity, reliability and maintainability over cleverness // and performance optimization where performance does not impact usability const func = async () => { const Data = [], Frames = [], Meta = [], State = {}; message = JSON.parse(message); DEBUG.cnx && console.log(message); const {fastestChannel, copeer, zombie, tabs, messageId} = message; let {screenshotAck} = message; latestMessageId = messageId; try { if ( fastestChannel ) { if ( DEBUG.chooseFastest ) { if ( fastestChannel.websocket ) { DEBUG.logFastest && console.log(`Fastest is websocket`); zl.act.addLink({so, forceMeta}, {connectionId, fastest: ws, peer, socket:ws}, zombie_port); } else if ( fastestChannel.webrtcpeer ) { DEBUG.logFastest && console.log(`Fastest is webrtc`); zl.act.addLink({so, forceMeta}, {connectionId, fastest: peer, peer, socket:ws}, zombie_port); } else { console.warn(`Unknown fastest channel`, fastestChannel); } } } if ( screenshotAck ) { (DEBUG.debugCast || DEBUG.acks) && console.log('client sent screenshot ack', screenshotAck); if ( screenshotAck == 1 ) { (DEBUG.debugCast || DEBUG.acks) && console.log('client sent screenshot no frame received code'); screenshotAck = { frameId: 1, requiresCastId: true } } if ( !screenshotAck.requiresCastId || DEBUG.allowAckBlastOnStart ) { zl.act.screenshotAck( connectionId, zombie_port, screenshotAck, {Data, Frames, Meta, State, receivesFrames: false, messageId} ); } } if ( zombie ) { const {events} = zombie; let {receivesFrames} = zombie; if ( receivesFrames ) { // switch it on in DEBUG and save it on the websocket for all future events ws.receivesFrames = receivesFrames; } else { receivesFrames = ws.receivesFrames; } // debug DEBUG.val > DEBUG.med && console.log(`Starting ${events.length} events for message ${messageId}`); await eventSendLoop(events, {Data, Frames, Meta, State, receivesFrames, messageId, connectionId}); // debug DEBUG.val > DEBUG.med && console.log(`Ending ${events.length} events for message ${messageId}\n`); const {totalBandwidth} = State; if ( DEBUG.sendFramesWhenTheyArrive ) { zl.act.fanOut(socket => so( socket, {messageId, data:Data, frameBuffer:[], meta:Meta, totalBandwidth} ), zombie_port); } else { if ( DEBUG.binaryFrames ) { if ( DEBUG.cwebp && ! zl.act.isSafari(zombie_port) ) { console.warn(`Not currently supported due to when dependency of cwebp not correctly building in esbuild build script ./scripts/only_build.sh`); /* for( let frame of Frames ) { const encoder = new CWebp(Buffer.from(frame, 'base64')); encoder.quality(WEBP_QUAL); frame = await encoder.toBuffer(); so(DEBUG.useWebRTC && peer ? peer : ws,frame); } */ } else { for( let frame of Frames ) { so(DEBUG.useWebRTC && peer ? peer : ws,frame); } } zl.act.fanOut( socket => so( socket, {messageId, data:Data, frameBuffer: [], meta:Meta, totalBandwidth} ), zombie_port ); } else { zl.act.fanOut(socket => so( socket, {messageId, data:Data, frameBuffer:Frames, meta:Meta, totalBandwidth} ), zombie_port); } } } if ( tabs ) { let {data:{targetInfos:targets, vmPaused, modal}} = await timedSend({ name: "Target.getTargets", params: { filter: [ {type: 'page'} ] }, }, zombie_port); if ( targets.length === 1 ) { zl.act.setHiddenTarget(targets[0].targetId, zombie_port); } targets = targets.filter(({targetId,type}) => { if ( type !== 'page' ) return false; if ( ! zl.act.hasSession(targetId, zombie_port) ) return false; return true; }); targets = targets.map(t => { if ( !TabNumbers.has(t.targetId) ) { TabNumber++; TabNumbers.set(t.targetId, TabNumber); } t.number = TabNumbers.get(t.targetId); return t; }); targets.sort(({number:A}, {number:B}) => A - B); const activeTarget = zl.act.getActiveTarget(zombie_port); zl.act.addTargets(targets, zombie_port); zl.act.fanOut( socket => so(socket,{messageId, activeTarget, tabs:targets}), zombie_port ); } if ( copeer ) { const {signal} = copeer; DEBUG.cnx && console.log('Received webrtc signal data from socket', copeer); peer.signal(signal); } } catch(e) { console.error("Error", IP, connectionId, e); // errors are not always pushed up this far // but when they are we can alert the client so(ws,{messageId, data:[ { error: "Failed to communicate with cloud browser", resetRequired: true } ], frameBuffer:[], meta:[], totalBandwidth: 0}); } }; Queue.funcs.push(func); if ( ! messageQueueRunning ) { runMessageQueue(); } }); ws.on('error', err => { DEBUG.debugConnect && console.log(`Check 10`); console.log(`ws err`, err); }); DEBUG.debugConnect && console.log(`Check 11`); // send favicons to client zl.act.sendFavicons(msg => so(ws, msg), zombie_port); DEBUG.debugConnect && console.log(`Check 12`); websockets.add(ws); // why the below? // for cases where we still want to alert the client // to an error we don't push all the way // (because say, the send() method that results in the error // has no corresponding result that it is expected to tell the client about // e.g. // await send("Command.name", params); // errors from here will not go to client zl.act.setClientErrorSender(err => { so(ws,{messageId:latestMessageId, data:[ { error: err, resetRequired: true } ], frameBuffer:[], meta:[], totalBandwidth: 0}); }, zombie_port); } else { const hadSession = !! cookie && cookie.includes(COOKIENAME+port); const msg = hadSession ? `Your session has expired. Please log back in.` : `No session detected, have you signed up yet?`; const error = {msg, willCloseSocket: true, hadSession}; console.log("Closing as not authorized.", cookie, hadSession, msg); so(ws, {messageId:1,data:[{error}]}); ws.close(); } }); server.listen(server_port, async err => { if ( err ) { console.error('err', err); process.exit(0); } else { addHandlers(); DEBUG.val && console.log({uptime:new Date, message:'websocket server up', server_port}); const pidFilePath = path.resolve(CONFIG.baseDir, `app-${server_port}.pid`); console.log(`Writing pid`, process.pid, pidFilePath); try { fs.writeFileSync(pidFilePath, process.pid.toString()); } catch(e) { console.log(e); } } }); process.on('SIGINT', shutDown); process.on('SIGUSR1', shutDown); process.on('SIGUSR2', shutDown); process.on('beforeExit', shutDown); process.on('exit', shutDown); return server; function addHandlers() { // core app interface functions app.get(`/api/${version}/tabs`, wrap(async (req, res) => { const cookie = req.cookies[COOKIENAME+port]; DEBUG.debugCookie && console.log('look for cookie', COOKIENAME+port, 'found: ', {cookie, allowed_user_cookie}); DEBUG.debugCookie && console.log('all cookies', req.cookies); if ( (cookie !== allowed_user_cookie) ) { return res.status(401).send('{"err":"forbidden"}'); } requestId++; res.type('json'); let targets, vmPaused, modal; try { ({data:{targetInfos:targets, vmPaused, modal}} = await timedSend({ name: "Target.getTargets", params: { filter: [ {type: 'page'} ] }, }, zombie_port)); if ( targets ) { if ( targets?.length === 1 ) { zl.act.setHiddenTarget(targets[0].targetId, zombie_port); } targets = targets.filter(({targetId,type}) => { if ( type !== 'page' ) return false; if ( ! zl.act.hasSession(targetId, zombie_port) ) return false; return true; }); targets = targets.map(t => { if ( !TabNumbers.has(t.targetId) ) { TabNumber++; TabNumbers.set(t.targetId, TabNumber); } t.number = TabNumbers.get(t.targetId); return t; }); targets.sort(({number:A}, {number:B}) => A - B); const activeTarget = zl.act.getActiveTarget(zombie_port); zl.act.addTargets(targets, zombie_port); res.end(JSON.stringify({tabs:targets,activeTarget,requestId,vmPaused, modal})); } else { res.end(JSON.stringify({vmPaused, modal})); } } catch(e) { console.warn('No target data from chrome. Normally means the VM is paused or Chrome is not open.', e); /*throw e;*/ } })); app.get(`/isTor`, (req, res) => { const cookie = req.cookies[COOKIENAME+port]; DEBUG.debugCookie && console.log('look for cookie', COOKIENAME+port, 'found: ', {cookie, allowed_user_cookie}); DEBUG.debugCookie && console.log('all cookies', req.cookies); if ( (cookie !== allowed_user_cookie) ) { return res.status(401).send('{"err":"forbidden"}'); } res.type('json'); const data = {}; if ( CONFIG.useTorProxy ) { data.isTor = true; } else { data.isTor = false; } res.end(JSON.stringify(data)); }); app.get("/settings_modal", (req, res) => { const cookie = req.cookies[COOKIENAME+port]; if ( (cookie !== allowed_user_cookie) ) { return res.status(401).send('{"err":"forbidden"}'); } res.status(200).send(` <form method=POST target=results> <input type=hidden name=_csrf value=${LatestCSRFToken}> <fieldset> <button formaction=/restart_app>Restart app</button> <button formaction=/stop_app>Stop app</button> <button formaction=/stop_browser>Stop browser</button> </fieldset> </form> <iframe style=display:none name=results> `); }); app.post("/file", async (req,res) => { const cookie = req.cookies[COOKIENAME+port]; if ( (cookie !== allowed_user_cookie) ) { DEBUG.debugFileUpload && console.log(`Request for file upload forbidden.`, req.files); return res.status(401).send('{"err":"forbidden"}'); } const {files} = req; const {sessionid:sessionId} = req.body; const backendNodeId = fileChoosers.get(sessionId); const action = ! files || files.length == 0 ? 'cancel' : 'accept'; const fileInputResult = await zl.act.send({ name:"Runtime.evaluate", params: { expression: "self.zombieDosyLastClicked.fileInput" }, definitelyWait: true, sessionId }, zombie_port); DEBUG.debugFileInput && console.log({fileInputResult, s:JSON.stringify(fileInputResult)}); const objectId = fileInputResult.data.result.objectId; const command = { name: "DOM.setFileInputFiles", params: { files: files && files.map(({path}) => path), backendNodeId }, sessionId }; DEBUG.debugFileUpload && console.log("We need to send the right command to the browser session", files, sessionId, action, command); let result; try { result = await zl.act.send(command, zombie_port); } catch(e) { console.log("Error sending file input command", e); } DEBUG.debugFileUpload && console.log(JSON.stringify({fileResult:result}, null, 2)); if ( !result || result.error ) { res.status(500).send(JSON.stringify({error:'there was an error attaching the files'})); } else { result = { success: true, files: files.map(({originalname,size}) => ({name:originalname,size})) }; DEBUG.val > DEBUG.med && console.log("Sent files to file input", result, files); res.json(result); } }); // app meta controls app.post("/restart_app", ConstrainedRateLimiter, (req, res) => { const cookie = req.cookies[COOKIENAME+port]; const url = new URL(req.url, `${req.protocol}://${req.headers.host}`); const qp = url.searchParams.get('session_token'); if ( (cookie !== allowed_user_cookie) && qp != session_token ) { return res.status(401).send('{"err":"forbidden"}'); } /** 1. queue a restart task 2. add an exit handler a. in exit handler check if pm2 is running us process.env.PM2_USAGE && process.env.name exists b. then call child_process.spawn 3. call process exit. That's it **/ if ( DEBUG.ensureUptimeBeforeRestart && process.uptime() < T2_MINUTES ) { DEBUG.debugRestart && console.info(`Denying restart request, reason: app is up for less than 2 minutes`); return res.status(403).send("deny"); } let norestart = false; DEBUG.debugRestart && console.info(`Queueing restart task...`); timer = setTimeout(function () { // Listen for the 'exit' event. // This is emitted when our app exits. DEBUG.debugRestart && console.info(`Adding exit handler`); process.on("exit", function () { if ( process.env.PM2_USAGE && process.env.name ) { DEBUG.debugRestart && console.info(`pm2 is running us, switching off restart and just exiting, as pm2 will restart us.`); norestart = true; } if ( norestart ) { DEBUG.debugRestart && console.info(`Not restarting this time.`); } DEBUG.debugRestart && console.info(`Spawning new process`, process.argv); child_process.spawn( process.argv.shift(), process.argv, { cwd: process.cwd(), detached: true, stdio: "inherit" } ); }); DEBUG.debugRestart && console.info(`Exiting parent process`); process.exit(); }, 1000); DEBUG.debugRestart && console.info(`Adding SIGINT restart interrupt handler...`); process.on('SIGINT', () => { DEBUG.debugRestart && console.info(`Got SIGINT during restart cycle. Not restarting...`); clearTimeout(timer); norestart = true; }); return res.status(200).send("requested"); }); app.post("/stop_app", ConstrainedRateLimiter, (req, res) => { const cookie = req.cookies[COOKIENAME+port]; const url = new URL(req.url, `${req.protocol}://${req.headers.host}`); const qp = url.searchParams.get('session_token'); if ( (cookie !== allowed_user_cookie) && qp != session_token ) { return res.status(401).send('{"err":"forbidden"}'); } /** call process.exit() if pm2 not running us. if pm2 running us, call pm2 delete (our name) **/ // queue a task to provide some time for jobs to complete on exit setTimeout(() => console.log('Exiting...'), 5000); console.log(process.env, process.argv, process.title); if ( process.env.PM2_USAGE && process.env.name ) { DEBUG.debugRestart && console.log(`Is pm2. Deleting pm2 name`, process.env.name); try { console.log(child_process.execSync(`pm2 delete ${process.env.name}`).toString()); } catch(e) { console.warn(e); } } else { DEBUG.debugRestart && console.log(`Is not pm2. Exiting process`); process.exit(0); } }); app.post("/stop_browser", async (req, res) => { const cookie = req.cookies[COOKIENAME+port]; const url = new URL(req.url, `${req.protocol}://${req.headers.host}`); const qp = url.searchParams.get('session_token'); if ( (cookie !== allowed_user_cookie) && qp != session_token ) { return res.status(401).send('{"err":"forbidden"}'); } /** kill the browser zombie from its pid file **/ await zl.life.kill(zombie_port); }); app.post("/start_browser", (req, res) => { const cookie = req.cookies[COOKIENAME+port]; const url = new URL(req.url, `${req.protocol}://${req.headers.host}`); const qp = url.searchParams.get('session_token'); if ( (cookie !== allowed_user_cookie) && qp != session_token ) { return res.status(401).send('{"err":"forbidden"}'); } /** launch a new browser (if one is not running) **/ }); // app integrity check app.get("/integrity", ConstrainedRateLimiter, (req, res) => { const cookie = req.cookies[COOKIENAME+port]; const url = new URL(req.url, `${req.protocol}://${req.headers.host}`); const qp = url.searchParams.get('session_token'); if ( (cookie !== allowed_user_cookie) && qp != session_token ) { return res.status(401).send('{"err":"forbidden"}'); } res.type('text'); res.status(200).send(INTEGRITY_FILE_CONTENT); }); // error handling middleware app.use('*', (err, req, res, next) => { try { res.type('json'); } finally { let message = ''; if ( DEBUG.dev && DEBUG.val ) { message = s({error: { msg: err.message, stack: err.stack.split(/\n/g) }}); } else { message = s({error: err.message || err+'', resetRequired:true}); } res.write(message); res.end(); if ( DEBUG.val ) { console.warn(err); } next(); } }); } function forceMeta(...metas) { zl.act.fanOut(socket => so( socket, { meta:[ ...metas ] } ), zombie_port); } function so(socket, message) { if ( !message ) return; if ( DEBUG.metaDebug && message.messageId ) { DEBUG.val && console.log("Server sending messageId", message.messageId, message.id); if ( Sent.has(message.messageId) ) { console.warn('Already sent a message with that ID!', message.messageId, Sent.get(message.messageId), 'versus', message); } else { Sent.set(message.messageId, message); DEBUG.val && console.log('Sending message id', message.messageId, 'for first time', message); } } if ( message instanceof Buffer ) { message; } else { if ( message.data ) { message.data = message.data.filter(x => !!x); } message = JSON.stringify(message); } try { socket.send(message); } catch(e) { DEBUG.val && console.warn(`${socket} error with sending message`, e, message); console.warn(`${socket} error with sending message`, e, message); if ( ! socket && websockets.size === 0 && zl.act.zombieIsDead(zombie_port) ) { console.log(`Zombie Chrome is dead and there are no clients. Shutting down.`); try { process.kill(process.pid, 'SIGINT'); shutDown(); } catch(e2) { console.warn(`error exit`, e2); } process.exit(0); } } } function runMessageQueue() { if ( messageQueueRunning ) return; messageQueueRunning = true; while( Queue.funcs.length ) { const func = Queue.funcs.shift(); /* try { await func(); } catch(e) { console.warn("error while running message queue", e); } */ func(); // await sleep(TIME_BETWEEN_MESSAGES); } messageQueueRunning = false; } // helpers function wrap(fn) { return async function handler(req, res, next) { try { await fn(req, res, next); } catch(e) { if ( DEBUG.val ) { console.info(`caught error in ${fn}`, e); } next(e); } } } function s(o) { let r; if ( typeof o == "string" ) r = 0; else try { r = JSON.stringify(o, null, 2); } catch(e) { DEBUG.val > DEBUG.hi && console.warn(e); } try { r = r + ''; } catch(e) { DEBUG.val > DEBUG.hi && console.warn(e); } return r; } } export function getInjectableAssetPath() { if ( ! serverOrigin ) { throw new ReferenceError` serverOrigin has not been set at time of call to getInjectableAssetPath `; } return `${serverOrigin}/assets`; } function nextFileName(ext = '') { //console.log({nextFileName:{ext}}); if ( ! ext.startsWith('.') ) { ext = '.' + ext; } const name = `file${(Math.random()*1000000).toString(36)}${ext}`; //console.log({nextFileName:{name}}); return name; }