diff --git a/bin/anyproxy b/bin/anyproxy index 330e083..108bc5d 100755 --- a/bin/anyproxy +++ b/bin/anyproxy @@ -1,6 +1,7 @@ #!/usr/bin/env node -'use strict' +'use strict'; + const program = require('commander'), color = require('colorful'), packageInfo = require('../package.json'), @@ -17,6 +18,7 @@ program .option('-i, --intercept', 'intercept(decrypt) https requests when root CA exists') .option('-s, --silent', 'do not print anything into terminal') .option('-c, --clear', 'clear all the certificates and temp files') + .option('--ws-intercept', 'intercept websocket') .option('--ignore-unauthorized-ssl', 'ignore all ssl error') .parse(process.argv); @@ -62,7 +64,8 @@ if (program.clear) { webInterface: { enable: true, webPort: program.web, - }, + }, + wsIntercept: program.wsIntercept, forceProxyHttps: program.intercept, dangerouslyIgnoreUnauthorized: !!program.ignoreUnauthorizedSsl, silent: program.silent @@ -107,7 +110,7 @@ if (program.clear) { } catch (e) {} logUtil.printLog(errorTipText, logUtil.T_ERR); try { - proxyServer && proxyServer.close(); + proxyServer && proxyServer.close(); } catch (e) {} process.exit(); }); diff --git a/docs-src/cn/src_doc.md b/docs-src/cn/src_doc.md index 02b9ab0..ae01fdc 100644 --- a/docs-src/cn/src_doc.md +++ b/docs-src/cn/src_doc.md @@ -84,6 +84,7 @@ const options = { }, throttle: 10000, forceProxyHttps: false, + wsIntercept: false, // 不开启websocket代理 silent: false }; const proxyServer = new AnyProxy.ProxyServer(options); @@ -110,6 +111,7 @@ proxyServer.close(); * `forceProxyHttps` {boolean} 是否强制拦截所有的https,忽略规则模块的返回,默认`false` * `silent` {boolean} 是否屏蔽所有console输出,默认`false` * `dangerouslyIgnoreUnauthorized` {boolean} 是否忽略请求中的证书错误,默认`false` + * `wsIntercept` {boolean} 是否开启websocket代理,默认`false` * `webInterface` {object} web版界面配置 * `enable` {boolean} 是否启用web版界面,默认`false` * `webPort` {number} web版界面端口号,默认`8002` diff --git a/docs-src/en/src_doc.md b/docs-src/en/src_doc.md index da11810..36872ed 100644 --- a/docs-src/en/src_doc.md +++ b/docs-src/en/src_doc.md @@ -83,6 +83,7 @@ const options = { }, throttle: 10000, forceProxyHttps: false, + wsIntercept: false, silent: false }; const proxyServer = new AnyProxy.ProxyServer(options); @@ -106,11 +107,12 @@ proxyServer.close(); * `port` {number} required, port number of proxy server * `rule` {object} your rule module * `throttle` {number} throttle in kb/s, unlimited for default - * `forceProxyHttps` {boolean} in force intercept all https request, false for default - * `silent` {boolean} if keep silent in console, false for default`false` - * `dangerouslyIgnoreUnauthorized` {boolean} if ignore certificate error in request, false for default + * `forceProxyHttps` {boolean} in force intercept all https request, default to `false` + * `silent` {boolean} if keep silent in console, false for default `false` + * `dangerouslyIgnoreUnauthorized` {boolean} if ignore certificate error in request, default to `false` + * `wsIntercept` {boolean} whether to intercept websocket, default to `false` * `webInterface` {object} config for web interface - * `enable` {boolean} if enable web interface, false for default + * `enable` {boolean} if enable web interface, default to `false` * `webPort` {number} port number for web interface * Event: `ready` * emit when proxy server is ready diff --git a/lib/httpsServerMgr.js b/lib/httpsServerMgr.js index d57c14a..e66c840 100644 --- a/lib/httpsServerMgr.js +++ b/lib/httpsServerMgr.js @@ -9,12 +9,12 @@ const async = require('async'), certMgr = require('./certMgr'), logUtil = require('./log'), util = require('./util'), + wsServerMgr = require('./wsServerMgr'), co = require('co'), constants = require('constants'), asyncTask = require('async-task-mgr'); const createSecureContext = tls.createSecureContext || crypto.createSecureContext; - //using sni to avoid multiple ports function SNIPrepareCert(serverName, SNICallback) { let keyContent, @@ -80,7 +80,6 @@ function createHttpsServer(config) { key: keyContent, cert: crtContent }, config.handler).listen(config.port); - resolve(server); }); }); @@ -131,6 +130,7 @@ class httpsServerMgr { this.instanceDefaultHost = '127.0.0.1'; this.httpsAsyncTask = new asyncTask(); this.handler = config.handler; + this.wsHandler = config.wsHandler } getSharedHttpsServer(hostname) { @@ -159,12 +159,15 @@ class httpsServerMgr { }); } - - httpsServer.on('upgrade', (req, socket, head) => { - const reqHost = req.headers.host || 'unknown host'; - logUtil.printLog(`wss:// is not supported when intercepting https. This request will be closed by AnyProxy. You may either exclude this domain in your rule file, or stop all https intercepting. (${reqHost})`, logUtil.T_ERR); - socket.end(); + wsServerMgr.getWsServer({ + server: httpsServer, + connHandler: self.wsHandler }); + + httpsServer.on('upgrade', (req, cltSocket, head) => { + logUtil.debug('will let WebSocket server to handle the upgrade event'); + }); + const result = { host: finalHost, port: instancePort, diff --git a/lib/log.js b/lib/log.js index 470a494..18adb3e 100644 --- a/lib/log.js +++ b/lib/log.js @@ -25,6 +25,7 @@ function printLog(content, type) { if (!ifPrint) { return; } + const timeString = util.formatDate(new Date(), 'YYYY-MM-DD hh:mm:ss'); switch (type) { case LogLevelMap.tip: { @@ -62,6 +63,7 @@ function printLog(content, type) { } case LogLevelMap.debug: { + console.log(color.cyan(`[AnyProxy Log][${timeString}]: ` + content)); return; } @@ -73,6 +75,27 @@ function printLog(content, type) { } module.exports.printLog = printLog; + +module.exports.debug = (content) => { + printLog(content, LogLevelMap.debug); +}; + +module.exports.info = (content) => { + printLog(content, LogLevelMap.tip); +}; + +module.exports.warn = (content) => { + printLog(content, LogLevelMap.warn); +}; + +module.exports.error = (content) => { + printLog(content, LogLevelMap.error); +}; + +module.exports.ruleError = (content) => { + printLog(content, LogLevelMap.rule_error); +}; + module.exports.setPrintStatus = setPrintStatus; module.exports.setLogLevel = setLogLevel; module.exports.T_TIP = LogLevelMap.tip; diff --git a/lib/recorder.js b/lib/recorder.js index 4bb49c0..1a37739 100644 --- a/lib/recorder.js +++ b/lib/recorder.js @@ -4,11 +4,30 @@ const Datastore = require('nedb'), path = require('path'), fs = require('fs'), + logUtil = require('./log'), events = require('events'), iconv = require('iconv-lite'), + fastJson = require('fast-json-stringify'), proxyUtil = require('./util'); +const wsMessageStingify = fastJson({ + title: 'ws message stringify', + type: 'object', + properties: { + time: { + type: 'integer' + }, + message: { + type: 'string' + }, + isToServer: { + type: 'boolean' + } + } +}); + const BODY_FILE_PRFIX = 'res_body_'; +const WS_MESSAGE_FILE_PRFIX = 'ws_message_'; const CACHE_DIR_PREFIX = 'cache_r'; function getCacheDir() { const rand = Math.floor(Math.random() * 1000000), @@ -85,6 +104,10 @@ class Recorder extends events.EventEmitter { } } + emitUpdateLatestWsMessage(id, message) { + this.emit('updateLatestWsMsg', message); + } + updateRecord(id, info) { if (id < 0) return; const self = this; @@ -98,6 +121,28 @@ class Recorder extends events.EventEmitter { self.emitUpdate(id, finalInfo); } + /** + * This method shall be called at each time there are new message + * + */ + updateRecordWsMessage(id, message) { + const cachePath = this.cachePath; + if (id < 0) return; + try { + const recordWsMessageFile = path.join(cachePath, WS_MESSAGE_FILE_PRFIX + id); + + fs.appendFile(recordWsMessageFile, wsMessageStingify(message) + ',', () => {}); + } catch (e) { + console.error(e); + logUtil.error(e.message + e.stack); + } + + this.emitUpdateLatestWsMessage(id, { + id: id, + message: message + }); + } + updateExtInfo(id, extInfo) { const self = this; const db = self.db; @@ -138,6 +183,10 @@ class Recorder extends events.EventEmitter { fs.writeFile(bodyFile, info.resBody, () => {}); } + /** + * get body and websocket file + * + */ getBody(id, cb) { const self = this; const cachePath = self.cachePath; @@ -159,6 +208,7 @@ class Recorder extends events.EventEmitter { getDecodedBody(id, cb) { const self = this; const result = { + method: '', type: 'unknown', mime: '', content: '' @@ -170,6 +220,9 @@ class Recorder extends events.EventEmitter { return; } + // also put the `method` back, so the client can decide whether to load ws messages + result.method = doc[0].method; + self.getBody(id, (error, bodyContent) => { if (error) { cb(error); @@ -212,6 +265,44 @@ class Recorder extends events.EventEmitter { }); } + /** + * get decoded WebSoket messages + * + */ + getDecodedWsMessage(id, cb) { + const self = this; + const cachePath = self.cachePath; + + if (id < 0) { + cb && cb([]); + } + + const wsMessageFile = path.join(cachePath, WS_MESSAGE_FILE_PRFIX + id); + fs.access(wsMessageFile, fs.F_OK || fs.R_OK, (err) => { + if (err) { + cb && cb(err); + } else { + fs.readFile(wsMessageFile, 'utf8', (error, content) => { + if (error) { + cb && cb(err); + } + + try { + // remove the last dash "," if it has, since it's redundant + // and also add brackets to make it a complete JSON structure + content = `[${content.replace(/,$/, '')}]`; + const messages = JSON.parse(content); + cb(null, messages); + } catch (e) { + console.error(e); + logUtil.error(e.message + e.stack); + cb(e); + } + }); + } + }); + } + getSingleRecord(id, cb) { const self = this; const db = self.db; diff --git a/lib/requestHandler.js b/lib/requestHandler.js index 1ed7e97..9757fd6 100644 --- a/lib/requestHandler.js +++ b/lib/requestHandler.js @@ -11,6 +11,7 @@ const http = require('http'), Stream = require('stream'), logUtil = require('./log'), co = require('co'), + WebSocket = require('ws'), HttpsServerMgr = require('./httpsServerMgr'), brotliTorb = require('brotli'), Readable = require('stream').Readable; @@ -201,6 +202,34 @@ function fetchRemoteResponse(protocol, options, reqData, config) { }); } +/** +* get request info from the ws client, includes: + host + port + path + protocol ws/wss + + @param @required wsClient the ws client of WebSocket +* +*/ +function getWsReqInfo(wsClient) { + const upgradeReq = wsClient.upgradeReq || {}; + const header = upgradeReq.headers || {}; + const host = header.host; + const hostName = host.split(':')[0]; + const port = host.split(':')[1]; + + // TODO 如果是windows机器,url是不是全路径?需要对其过滤,取出 + const path = upgradeReq.url || '/'; + + const isEncript = true && upgradeReq.connection && upgradeReq.connection.encrypted; + return { + hostName: hostName, + port: port, + path: path, + protocol: isEncript ? 'wss' : 'ws' + }; +} /** * get a request handler for http/https server * @@ -471,10 +500,10 @@ function getConnectReqHandler(userRule, recorder, httpsServerMgr) { const host = req.url.split(':')[0], targetPort = req.url.split(':')[1]; let shouldIntercept; + let interceptWsRequest = false; let requestDetail; let resourceInfo = null; let resourceInfoId = -1; - const requestStream = new CommonReadableStream(); /* @@ -487,14 +516,17 @@ function getConnectReqHandler(userRule, recorder, httpsServerMgr) { co(function *() { // determine whether to use the man-in-the-middle server logUtil.printLog(color.green('received https CONNECT request ' + host)); - if (reqHandlerCtx.forceProxyHttps) { - shouldIntercept = true; - } else { - requestDetail = { - host: req.url, - _req: req - }; - shouldIntercept = yield userRule.beforeDealHttpsRequest(requestDetail); + requestDetail = { + host: req.url, + _req: req + }; + // the return value in default rule is null + // so if the value is null, will take it as final value + shouldIntercept = yield userRule.beforeDealHttpsRequest(requestDetail); + + // otherwise, will take the passed in option + if (shouldIntercept === null) { + shouldIntercept = reqHandlerCtx.forceProxyHttps; } }) .then(() => @@ -513,7 +545,13 @@ function getConnectReqHandler(userRule, recorder, httpsServerMgr) { try { const chunkString = chunk.toString(); if (chunkString.indexOf('GET ') === 0) { - shouldIntercept = false; //websocket + shouldIntercept = false; // websocket, do not intercept + + // if there is '/do-not-proxy' in the request, do not intercept the websocket + // to avoid AnyProxy itself be proxied + if (reqHandlerCtx.wsIntercept && chunkString.indexOf('GET /do-not-proxy') !== 0) { + interceptWsRequest = true; + } } } catch (e) { console.error(e); @@ -550,10 +588,19 @@ function getConnectReqHandler(userRule, recorder, httpsServerMgr) { .then(() => { // determine the request target if (!shouldIntercept) { - return { + // server info from the original request + const originServer = { host, - port: (targetPort === 80) ? 443 : targetPort, + port: (targetPort === 80) ? 443 : targetPort } + + const localHttpServer = { + host: 'localhost', + port: reqHandlerCtx.httpServerPort + } + + // for ws request, redirect them to local ws server + return interceptWsRequest ? localHttpServer : originServer; } else { return httpsServerMgr.getSharedHttpsServer(host).then(serverInfo => ({ host: serverInfo.host, port: serverInfo.port })); } @@ -613,6 +660,158 @@ function getConnectReqHandler(userRule, recorder, httpsServerMgr) { } } +/** +* get a websocket event handler + @param @required {object} wsClient +*/ +function getWsHandler(userRule, recorder, wsClient) { + const self = this; + try { + let resourceInfoId = -1; + const resourceInfo = { + wsMessages: [] // all ws messages go through AnyProxy + }; + const clientMsgQueue = []; + const serverInfo = getWsReqInfo(wsClient); + const wsUrl = `${serverInfo.protocol}://${serverInfo.hostName}:${serverInfo.port}${serverInfo.path}`; + const proxyWs = new WebSocket(wsUrl, '', { + rejectUnauthorized: !self.dangerouslyIgnoreUnauthorized + }); + + if (recorder) { + Object.assign(resourceInfo, { + host: serverInfo.hostName, + method: 'WebSocket', + path: serverInfo.path, + url: wsUrl, + req: wsClient.upgradeReq || {}, + startTime: new Date().getTime() + }); + resourceInfoId = recorder.appendRecord(resourceInfo); + } + + /** + * store the messages before the proxy ws is ready + */ + const sendProxyMessage = (event) => { + const message = event.data; + if (proxyWs.readyState === 1) { + // if there still are msg queue consuming, keep it going + if (clientMsgQueue.length > 0) { + clientMsgQueue.push(message); + } else { + proxyWs.send(message); + } + } else { + clientMsgQueue.push(message); + } + } + + /** + * consume the message in queue when the proxy ws is not ready yet + * will handle them from the first one-by-one + */ + const consumeMsgQueue = () => { + while (clientMsgQueue.length > 0) { + const message = clientMsgQueue.shift(); + proxyWs.send(message); + } + } + + /** + * When the source ws is closed, we need to close the target websocket. + * If the source ws is normally closed, that is, the code is reserved, we need to transfrom them + */ + const getCloseFromOriginEvent = (event) => { + const code = event.code || ''; + const reason = event.reason || ''; + let targetCode = ''; + let targetReason = ''; + if (code >= 1004 && code <= 1006) { + targetCode = 1000; // normal closure + targetReason = `Normally closed. The origin ws is closed at code: ${code} and reason: ${reason}`; + } else { + targetCode = code; + targetReason = reason; + } + + return { + code: targetCode, + reason: targetReason + } + } + + /** + * consruct a message Record from message event + * @param @required {event} messageEvent the event from websockt.onmessage + * @param @required {boolean} isToServer whether the message is to or from server + * + */ + const recordMessage = (messageEvent, isToServer) => { + const message = { + time: Date.now(), + message: messageEvent.data, + isToServer: isToServer + }; + + // resourceInfo.wsMessages.push(message); + recorder && recorder.updateRecordWsMessage(resourceInfoId, message); + }; + + proxyWs.onopen = () => { + consumeMsgQueue(); + } + + // this event is fired when the connection is build and headers is returned + proxyWs.on('headers', (headers, response) => { + resourceInfo.endTime = new Date().getTime(); + resourceInfo.res = { //construct a self-defined res object + statusCode: response.statusCode, + headers: headers, + }; + + resourceInfo.statusCode = response.statusCode; + resourceInfo.resHeader = headers; + resourceInfo.resBody = ''; + resourceInfo.length = resourceInfo.resBody.length; + + recorder && recorder.updateRecord(resourceInfoId, resourceInfo); + }); + + proxyWs.onerror = (e) => { + // https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent#Status_codes + wsClient.close(1001, e.message); + proxyWs.close(1001); + } + + proxyWs.onmessage = (event) => { + recordMessage(event, false); + wsClient.readyState === 1 && wsClient.send(event.data); + } + + proxyWs.onclose = (event) => { + logUtil.debug(`proxy ws closed with code: ${event.code} and reason: ${event.reason}`); + const targetCloseInfo = getCloseFromOriginEvent(event); + wsClient.readyState !== 3 && wsClient.close(targetCloseInfo.code, targetCloseInfo.reason); + } + + wsClient.onmessage = (event) => { + recordMessage(event, true); + sendProxyMessage(event); + } + + wsClient.onclose = (event) => { + logUtil.debug(`original ws closed with code: ${event.code} and reason: ${event.reason}`); + const targetCloseInfo = getCloseFromOriginEvent(event); + proxyWs.readyState !== 3 && proxyWs.close(targetCloseInfo.code, targetCloseInfo.reason); + } + } catch (e) { + logUtil.debug('WebSocket Proxy Error:' + e.message); + logUtil.debug(e.stack); + console.error(e); + } +} + class RequestHandler { /** @@ -621,6 +820,7 @@ class RequestHandler { * @param {object} config * @param {boolean} config.forceProxyHttps proxy all https requests * @param {boolean} config.dangerouslyIgnoreUnauthorized + @param {number} config.httpServerPort the http port AnyProxy do the proxy * @param {object} rule * @param {Recorder} recorder * @@ -628,22 +828,36 @@ class RequestHandler { */ constructor(config, rule, recorder) { const reqHandlerCtx = this; + this.forceProxyHttps = false; + this.dangerouslyIgnoreUnauthorized = false; + this.httpServerPort = ''; + this.wsIntercept = false; + if (config.forceProxyHttps) { this.forceProxyHttps = true; } + if (config.dangerouslyIgnoreUnauthorized) { this.dangerouslyIgnoreUnauthorized = true; } + + if (config.wsIntercept) { + this.wsIntercept = config.wsIntercept; + } + + this.httpServerPort = config.httpServerPort; const default_rule = util.freshRequire('./rule_default'); const userRule = util.merge(default_rule, rule); reqHandlerCtx.userRequestHandler = getUserReqHandler.apply(reqHandlerCtx, [userRule, recorder]); + reqHandlerCtx.wsHandler = getWsHandler.bind(this, userRule, recorder); reqHandlerCtx.httpsServerMgr = new HttpsServerMgr({ - handler: reqHandlerCtx.userRequestHandler + handler: reqHandlerCtx.userRequestHandler, + wsHandler: reqHandlerCtx.wsHandler // websocket }); - this.connectReqHandler = getConnectReqHandler.apply(reqHandlerCtx, [userRule, recorder, reqHandlerCtx.httpsServerMgr]) + this.connectReqHandler = getConnectReqHandler.apply(reqHandlerCtx, [userRule, recorder, reqHandlerCtx.httpsServerMgr]); } } diff --git a/lib/rule_default.js b/lib/rule_default.js index e5c441e..dc74910 100644 --- a/lib/rule_default.js +++ b/lib/rule_default.js @@ -1,12 +1,12 @@ 'use strict'; module.exports = { - + summary: 'the default rule for AnyProxy', - + /** - * - * + * + * * @param {object} requestDetail * @param {string} requestDetail.protocol * @param {object} requestDetail.requestOptions @@ -23,8 +23,8 @@ module.exports = { /** - * - * + * + * * @param {object} requestDetail * @param {object} responseDetail */ @@ -34,21 +34,22 @@ module.exports = { /** - * - * - * @param {any} requestDetail - * @returns + * default to return null + * the user MUST return a boolean when they do implement the interface in rule + * + * @param {any} requestDetail + * @returns */ *beforeDealHttpsRequest(requestDetail) { - return false; + return null; }, /** - * - * - * @param {any} requestDetail - * @param {any} error - * @returns + * + * + * @param {any} requestDetail + * @param {any} error + * @returns */ *onError(requestDetail, error) { return null; @@ -56,11 +57,11 @@ module.exports = { /** - * - * - * @param {any} requestDetail - * @param {any} error - * @returns + * + * + * @param {any} requestDetail + * @param {any} error + * @returns */ *onConnectError(requestDetail, error) { return null; diff --git a/lib/util.js b/lib/util.js index f101c39..a09e924 100644 --- a/lib/util.js +++ b/lib/util.js @@ -4,8 +4,8 @@ const fs = require('fs'), path = require('path'), mime = require('mime-types'), color = require('colorful'), + crypto = require('crypto'), Buffer = require('buffer').Buffer, - configUtil = require('./configUtil'), logUtil = require('./log'); const networkInterfaces = require('os').networkInterfaces(); @@ -35,7 +35,7 @@ function getUserHome() { module.exports.getUserHome = getUserHome; function getAnyProxyHome() { - const home = configUtil.getAnyProxyHome(); + const home = path.join(getUserHome(), '/.anyproxy/'); if (!fs.existsSync(home)) { fs.mkdirSync(home); } @@ -306,3 +306,18 @@ module.exports.isIpDomain = function (domain) { return ipReg.test(domain); }; + +/** +* To generic a Sec-WebSocket-Accept value +* 1. append the `Sec-WebSocket-Key` request header with `matic string` +* 2. get sha1 hash of the string +* 3. get base64 of the sha1 hash +*/ +module.exports.genericWsSecAccept = function (wsSecKey) { + // the string to generate the Sec-WebSocket-Accept + const magicString = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; + const targetString = `${wsSecKey}${magicString}`; + const shasum = crypto.createHash('sha1'); + shasum.update(targetString); + return shasum.digest('base64'); +} diff --git a/lib/webInterface.js b/lib/webInterface.js index 4ee90a3..6c04b32 100644 --- a/lib/webInterface.js +++ b/lib/webInterface.js @@ -128,6 +128,7 @@ class webInterface extends events.EventEmitter { res.json({ id: query.id, type: result.type, + method: result.meethod, fileName: result.fileName, ref: `/downloadBody?id=${query.id}&download=${isDownload}&raw=${!isDownload}` }); @@ -143,6 +144,7 @@ class webInterface extends events.EventEmitter { res.json({ id: query.id, type: result.type, + method: result.method, resBody: result.content }); }; @@ -188,6 +190,22 @@ class webInterface extends events.EventEmitter { } }); + app.get('/fetchWsMessages', (req, res) => { + const query = req.query; + if (query && query.id) { + recorder.getDecodedWsMessage(query.id, (err, messages) => { + if (err) { + console.error(err); + res.json([]); + return; + } + res.json(messages); + }); + } else { + res.json([]); + } + }); + app.get('/fetchCrtFile', (req, res) => { res.setHeader('Access-Control-Allow-Origin', '*'); const _crtFilePath = certMgr.getRootCAFilePath(); diff --git a/lib/wsServer.js b/lib/wsServer.js index e1fa39c..6fe69c5 100644 --- a/lib/wsServer.js +++ b/lib/wsServer.js @@ -99,7 +99,11 @@ class wsServer { wss.broadcast = function (data) { if (typeof data === 'object') { - data = JSON.stringify(data); + try { + data = JSON.stringify(data); + } catch (e) { + console.error('==> errorr when do broadcast ', e, data); + } } for (const client of wss.clients) { try { @@ -137,6 +141,20 @@ class wsServer { } }); + recorder.on('updateLatestWsMsg', (data) => { + try { + // console.info('==> update latestMsg ', data); + wss && wss.broadcast({ + type: 'updateLatestWsMsg', + content: data + }); + } catch (e) { + logUtil.error(e.message); + logUtil.error(e.stack); + console.error(e); + } + }); + self.wss = wss; }); } diff --git a/lib/wsServerMgr.js b/lib/wsServerMgr.js new file mode 100644 index 0000000..62e5a50 --- /dev/null +++ b/lib/wsServerMgr.js @@ -0,0 +1,39 @@ +/** +* manage the websocket server +* +*/ +const ws = require('ws'); +const logUtil = require('./log.js'); + +const WsServer = ws.Server; + +/** +* get a new websocket server based on the server +* @param @required {object} config + {string} config.server + {handler} config.handler +*/ +function getWsServer(config) { + const wss = new WsServer({ + server: config.server + }); + + wss.on('connection', config.connHandler); + + wss.on('headers', (headers) => { + headers.push('x-anyproxy-websocket:true'); + }); + + wss.on('error', e => { + logUtil.error(`error in websocket proxy: ${e.message},\r\n ${e.stack}`); + console.error('error happened in proxy websocket:', e) + }); + + wss.on('close', e => { + console.error('==> closing the ws server'); + }); + + return wss; +} + +module.exports.getWsServer = getWsServer; diff --git a/package.json b/package.json index 0e089ca..3eb4250 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "compression": "^1.4.4", "es6-promise": "^3.3.1", "express": "^4.8.5", + "fast-json-stringify": "^0.17.0", "iconv-lite": "^0.4.6", "inquirer": "^3.0.1", "ip": "^0.3.2", diff --git a/proxy.js b/proxy.js index bf495d5..b907c76 100644 --- a/proxy.js +++ b/proxy.js @@ -11,6 +11,7 @@ const http = require('http'), events = require('events'), co = require('co'), WebInterface = require('./lib/webInterface'), + wsServerMgr = require('./lib/wsServerMgr'), ThrottleGroup = require('stream-throttle').ThrottleGroup; // const memwatch = require('memwatch-next'); @@ -60,6 +61,7 @@ class ProxyCore extends events.EventEmitter { * @param {boolean} [config.silent=false] - if keep the console silent * @param {boolean} [config.dangerouslyIgnoreUnauthorized=false] - if ignore unauthorized server response * @param {object} [config.recorder] - recorder to use + * @param {boolean} [config.wsIntercept] - whether intercept websocket * * @memberOf ProxyCore */ @@ -114,6 +116,8 @@ class ProxyCore extends events.EventEmitter { // init request handler const RequestHandler = util.freshRequire('./requestHandler'); this.requestHandler = new RequestHandler({ + wsIntercept: config.wsIntercept, + httpServerPort: config.port, // the http server port for http proxy forceProxyHttps: !!config.forceProxyHttps, dangerouslyIgnoreUnauthorized: !!config.dangerouslyIgnoreUnauthorized }, this.proxyRule, this.recorder); @@ -185,6 +189,10 @@ class ProxyCore extends events.EventEmitter { }, function (callback) { + wsServerMgr.getWsServer({ + server: self.httpProxyServer, + connHandler: self.requestHandler.wsHandler + }); // remember all sockets, so we can destory them when call the method 'close'; self.httpProxyServer.on('connection', (socket) => { self.handleExistConnections.call(self, socket); @@ -324,17 +332,10 @@ class ProxyServer extends ProxyCore { this.webServerInstance = new WebInterface(this.proxyWebinterfaceConfig, this.recorder); } - new Promise((resolve) => { - // start web server - if (this.webServerInstance) { - resolve(this.webServerInstance.start()); - } else { - resolve(null); - } - }) - .then(() => { + // start web server + this.webServerInstance.start().then(() => { // start proxy core - super.start() + super.start(); }) .catch((e) => { this.emit('error', e); diff --git a/test/server/server.js b/test/server/server.js index d674633..cdb33e7 100644 --- a/test/server/server.js +++ b/test/server/server.js @@ -310,7 +310,7 @@ KoaServer.prototype.start = function () { }); }); - wss.on('error', e => console.error('erro happened in wss:%s', error)); + wss.on('error', e => console.error('error happened in wss:%s', e)); self.httpsServer.listen(HTTPS_PORT); diff --git a/test/spec_rule/no_rule_websocket_spec.js b/test/spec_rule/no_rule_websocket_spec.js index 94bc999..7feae15 100644 --- a/test/spec_rule/no_rule_websocket_spec.js +++ b/test/spec_rule/no_rule_websocket_spec.js @@ -6,23 +6,32 @@ const ProxyServerUtil = require('../util/ProxyServerUtil.js'); const { generateWsUrl, directWs, proxyWs } = require('../util/HttpUtil.js'); const Server = require('../server/server.js'); -const { printLog } = require('../util/CommonUtil.js'); +const { printLog, isArrayEqual } = require('../util/CommonUtil.js'); testWebsocket('ws'); testWebsocket('wss'); +testWebsocket('ws', true); +testWebsocket('wss', true); -function testWebsocket(protocol) { +function testWebsocket(protocol, masked = false) { describe('Test WebSocket in protocol : ' + protocol, () => { const url = generateWsUrl(protocol, '/test/socket'); let serverInstance; let proxyServer; + // the message to + const testMessageArray = [ + 'Send the message with default option1', + 'Send the message with default option2', + 'Send the message with default option3', + 'Send the message with default option4' + ]; beforeAll((done) => { jasmine.DEFAULT_TIMEOUT_INTERVAL = 200000; printLog('Start server for no_rule_websocket_spec'); serverInstance = new Server(); - proxyServer = ProxyServerUtil.proxyServerWithoutHttpsIntercept(); + proxyServer = ProxyServerUtil.defaultProxyServer(); setTimeout(() => { done(); @@ -36,32 +45,57 @@ function testWebsocket(protocol) { }); it('Default websocket option', done => { - const sendMessage = 'Send the message with default option'; - let directMessage; // set the flag for direct message, compare when both direct and proxy got message - let proxyMessage; + const directMessages = []; // set the flag for direct message, compare when both direct and proxy got message + const proxyMessages = []; + let directHeaders; + let proxyHeaders; const ws = directWs(url); - const porxyWsRef = proxyWs(url); + const proxyWsRef = proxyWs(url); ws.on('open', () => { - ws.send(sendMessage); + ws.send(testMessageArray[0], masked); + for (let i = 1; i < testMessageArray.length; i++) { + setTimeout(() => { + ws.send(testMessageArray[i], masked); + }, 1000); + } }); - porxyWsRef.on('open', () => { - porxyWsRef.send(sendMessage); + proxyWsRef.on('open', () => { + try { + proxyWsRef.send(testMessageArray[0], masked); + for (let i = 1; i < testMessageArray.length; i++) { + setTimeout(() => { + proxyWsRef.send(testMessageArray[i], masked); + }, 1000); + } + } catch (e) { + console.error(e); + } + }); + + ws.on('headers', (headers) => { + directHeaders = headers; + compareMessageIfReady(); + }); + + proxyWsRef.on('headers', (headers) => { + proxyHeaders = headers; + compareMessageIfReady(); }); ws.on('message', (data, flag) => { const message = JSON.parse(data); if (message.type === 'onMessage') { - directMessage = message.content; + directMessages.push(message.content); compareMessageIfReady(); } }); - porxyWsRef.on('message', (data, flag) => { + proxyWsRef.on('message', (data, flag) => { const message = JSON.parse(data); if (message.type === 'onMessage') { - proxyMessage = message.content; + proxyMessages.push(message.content); compareMessageIfReady(); } }); @@ -71,70 +105,24 @@ function testWebsocket(protocol) { done.fail('Error happened in direct websocket'); }); - porxyWsRef.on('error', error => { + proxyWsRef.on('error', error => { console.error('error happened in proxy websocket:', error); done.fail('Error happened in proxy websocket'); }); function compareMessageIfReady() { - if (directMessage && proxyMessage) { - expect(directMessage).toEqual(proxyMessage); - expect(directMessage).toEqual(sendMessage); - done(); - } - } - }); - - it('masked:true', done => { - const sendMessage = 'Send the message with option masked:true'; - let directMessage; // set the flag for direct message, compare when both direct and proxy got message - let proxyMessage; - - const ws = directWs(url); - const porxyWsRef = proxyWs(url); - ws.on('open', () => { - ws.send(sendMessage, { masked: true }); - }); - - porxyWsRef.on('open', () => { - porxyWsRef.send(sendMessage, { masked: true }); - }); - - ws.on('message', (data, flag) => { - const message = JSON.parse(data); - if (message.type === 'onMessage') { - directMessage = message.content; - compareMessageIfReady(); - } - }); - - porxyWsRef.on('message', (data, flag) => { - const message = JSON.parse(data); - if (message.type === 'onMessage') { - proxyMessage = message.content; - compareMessageIfReady(); - } - }); - - ws.on('error', error => { - console.error('error happened in direct websocket:', error); - done.fail('Error happened in direct websocket'); - }); - - porxyWsRef.on('error', error => { - console.error('error happened in proxy websocket:', error); - - done.fail('Error happened in proxy websocket'); - }); - - function compareMessageIfReady() { - if (directMessage && proxyMessage) { - expect(directMessage).toEqual(proxyMessage); - expect(directMessage).toEqual(sendMessage); + const targetLen = testMessageArray.length; + if (directMessages.length === targetLen + && proxyMessages.length === targetLen + && directHeaders && proxyHeaders + ) { + expect(isArrayEqual(directMessages, testMessageArray)).toBe(true); + expect(isArrayEqual(directMessages, proxyMessages)).toBe(true); + expect(directHeaders['x-anyproxy-websocket']).toBeUndefined(); + expect(proxyHeaders['x-anyproxy-websocket']).toBe('true'); done(); } } }); }); } - diff --git a/test/util/CommonUtil.js b/test/util/CommonUtil.js index 1d20e7e..8ba1f94 100644 --- a/test/util/CommonUtil.js +++ b/test/util/CommonUtil.js @@ -264,5 +264,6 @@ module.exports = { printHilite, isCommonReqEqual, parseUrlQuery, - stringSimilarity + stringSimilarity, + isArrayEqual: _isDeepEqual }; diff --git a/test/util/ProxyServerUtil.js b/test/util/ProxyServerUtil.js index e3ce885..1974040 100644 --- a/test/util/ProxyServerUtil.js +++ b/test/util/ProxyServerUtil.js @@ -13,6 +13,7 @@ const DEFAULT_OPTIONS = { webPort: 8002, // optional, port for web interface wsPort: 8003, // optional, internal port for web socket }, + wsIntercept: true, throttle: 10000, // optional, speed limit in kb/s forceProxyHttps: true, // intercept https as well dangerouslyIgnoreUnauthorized: true, diff --git a/web/src/common/WsUtil.js b/web/src/common/WsUtil.js index 1aeec99..95835ba 100644 --- a/web/src/common/WsUtil.js +++ b/web/src/common/WsUtil.js @@ -4,12 +4,21 @@ */ import { message } from 'antd'; -export function initWs(wsPort = 8003, key = '') { +/** +* Initiate a ws connection. +* The default pay `do-not-proxy` means the ws do not need to be proxied. +* This is very important for AnyProxy its' own server, such as WEB UI, and the +* websocket detail panel, to prevent a recursive proxy. +* @param {wsPort} wsPort the port of websocket +* @param {key} path the path of the ws url +* +*/ +export function initWs(wsPort = 8003, path = 'do-not-proxy') { if(!WebSocket){ throw (new Error('WebSocket is not supportted on this browser')); } - const wsClient = new WebSocket(`ws://${location.hostname}:${wsPort}/${key}`); + const wsClient = new WebSocket(`ws://${location.hostname}:${wsPort}/${path}`); wsClient.onerror = (error) => { console.error(error); @@ -30,4 +39,3 @@ export function initWs(wsPort = 8003, key = '') { export default { initWs: initWs }; - diff --git a/web/src/common/commonUtil.js b/web/src/common/commonUtil.js index 812a5eb..5a3f360 100644 --- a/web/src/common/commonUtil.js +++ b/web/src/common/commonUtil.js @@ -11,10 +11,11 @@ export function formatDate(date, formatter) { if (typeof date !== 'object') { date = new Date(date); } + const transform = function(value) { return value < 10 ? '0' + value : value; }; - return formatter.replace(/^YYYY|MM|DD|hh|mm|ss/g, function(match) { + return formatter.replace(/^YYYY|MM|DD|hh|mm|ss|ms/g, function(match) { switch (match) { case 'YYYY': return transform(date.getFullYear()); @@ -28,6 +29,8 @@ export function formatDate(date, formatter) { return transform(date.getHours()); case 'ss': return transform(date.getSeconds()); + case 'ms': + return transform(date.getMilliseconds()); } }); } diff --git a/web/src/component/record-detail.jsx b/web/src/component/record-detail.jsx index 698a336..a3c4c9a 100644 --- a/web/src/component/record-detail.jsx +++ b/web/src/component/record-detail.jsx @@ -5,15 +5,12 @@ import React, { PropTypes } from 'react'; import ClassBind from 'classnames/bind'; -import { Menu, Table, notification, Spin } from 'antd'; -import clipboard from 'clipboard-js' -import JsonViewer from 'component/json-viewer'; +import { Menu, Spin } from 'antd'; import ModalPanel from 'component/modal-panel'; import RecordRequestDetail from 'component/record-request-detail'; import RecordResponseDetail from 'component/record-response-detail'; +import RecordWsMessageDetail from 'component/record-ws-message-detail'; import { hideRecordDetail } from 'action/recordAction'; -import { selectText } from 'common/CommonUtil'; -import { curlify } from 'common/curlUtil'; import Style from './record-detail.less'; import CommonStyle from '../style/common.less'; @@ -21,7 +18,8 @@ import CommonStyle from '../style/common.less'; const StyleBind = ClassBind.bind(Style); const PageIndexMap = { REQUEST_INDEX: 'REQUEST_INDEX', - RESPONSE_INDEX: 'RESPONSE_INDEX' + RESPONSE_INDEX: 'RESPONSE_INDEX', + WEBSOCKET_INDEX: 'WEBSOCKET_INDEX' }; // the maximum length of the request body to decide whether to offer a download link for the request body @@ -54,6 +52,10 @@ class RecordDetail extends React.Component { }); } + hasWebSocket (recordDetail = {}) { + return recordDetail && recordDetail.method && recordDetail.method.toLowerCase() === 'websocket'; + } + getRequestDiv(recordDetail) { return ; } @@ -62,18 +64,45 @@ class RecordDetail extends React.Component { return ; } - getRecordContentDiv(recordDetail, fetchingRecord) { + getWsMessageDiv(recordDetail) { + const { globalStatus } = this.props; + return ; + } + + getRecordContentDiv(recordDetail = {}, fetchingRecord) { const getMenuBody = () => { - const menuBody = this.state.pageIndex === PageIndexMap.REQUEST_INDEX ? - this.getRequestDiv(recordDetail) : this.getResponseDiv(recordDetail); + let menuBody = null; + switch (this.state.pageIndex) { + case PageIndexMap.REQUEST_INDEX: { + menuBody = this.getRequestDiv(recordDetail); + break; + } + case PageIndexMap.RESPONSE_INDEX: { + menuBody = this.getResponseDiv(recordDetail); + break; + } + case PageIndexMap.WEBSOCKET_INDEX: { + menuBody = this.getWsMessageDiv(recordDetail); + break; + } + default: { + menuBody = this.getRequestDiv(recordDetail); + break; + } + } return menuBody; } + const websocketMenu = ( + WebSocket + ); + return (
Request Response + {this.hasWebSocket(recordDetail) ? websocketMenu : null}
{fetchingRecord ? this.getLoaingDiv() : getMenuBody()} @@ -92,8 +121,9 @@ class RecordDetail extends React.Component { } getRecordDetailDiv() { - const recordDetail = this.props.requestRecord.recordDetail; - const fetchingRecord = this.props.globalStatus.fetchingRecord; + const { requestRecord, globalStatus } = this.props; + const recordDetail = requestRecord.recordDetail; + const fetchingRecord = globalStatus.fetchingRecord; if (!recordDetail && !fetchingRecord) { return null; @@ -101,6 +131,17 @@ class RecordDetail extends React.Component { return this.getRecordContentDiv(recordDetail, fetchingRecord); } + componentWillReceiveProps(nextProps) { + const { requestRecord } = nextProps; + const { pageIndex } = this.state; + // if this is not websocket, reset the index to RESPONSE_INDEX + if (!this.hasWebSocket(requestRecord.recordDetail) && pageIndex === PageIndexMap.WEBSOCKET_INDEX) { + this.setState({ + pageIndex: PageIndexMap.RESPONSE_INDEX + }); + } + } + render() { return ( { + const { message: wsMessage } = props; + return ( +
+
{formatDate(wsMessage.time, 'hh:mm:ss:ms')}
+
{wsMessage.message}
+
+ ); +} + +const FromMessage = (props) => { + const { message: wsMessage } = props; + return ( +
+
{formatDate(wsMessage.time, 'hh:mm:ss:ms')}
+
{wsMessage.message}
+
+ ); +} + +class RecordWsMessageDetail extends React.Component { + constructor() { + super(); + this.state = { + stateCheck: false, // a prop only to trigger state check + autoRefresh: true, + socketMessages: [] // the messages from websocket listening + }; + + this.updateStateRef = null; // a timeout ref to reduce the calling of update state + this.wsClient = null; // ref to the ws client + this.onMessageHandler = this.onMessageHandler.bind(this); + this.receiveNewMessage = this.receiveNewMessage.bind(this); + this.toggleRefresh = this.toggleRefresh.bind(this); + } + + static propTypes = { + recordDetail: PropTypes.object, + wsPort: PropTypes.number + } + + toggleRefresh () { + const { autoRefresh } = this.state; + this.state.autoRefresh = !autoRefresh; + this.setState({ + stateCheck: true + }); + } + + receiveNewMessage (message) { + this.state.socketMessages.push(message); + + this.updateStateRef && clearTimeout(this.updateStateRef); + this.updateStateRef = setTimeout(() => { + this.setState({ + stateCheck: true + }); + }, 100); + } + + getMessageList () { + const { recordDetail } = this.props; + const { socketMessages } = this.state; + const { wsMessages = [] } = recordDetail; + + const targetMessage = wsMessages.concat(socketMessages); + + return targetMessage.map((messageItem, index) => { + return messageItem.isToServer ? + : ; + }); + } + + refreshPage () { + const { autoRefresh } = this.state; + if (autoRefresh && this.messageRef && this.messageContentRef) { + this.messageRef.scrollTop = this.messageContentRef.scrollHeight; + } + } + + onMessageHandler (event) { + const { recordDetail } = this.props; + const data = JSON.parse(event.data); + const content = data.content; + if (data.type === 'updateLatestWsMsg' ) { + if (recordDetail.id === content.id) { + this.receiveNewMessage(content.message); + } + } + } + + componentDidUpdate () { + this.refreshPage(); + } + + componentWillUnmount () { + this.wsClient && this.wsClient.removeEventListener('message', this.onMessageHandler); + } + + componentDidMount () { + const { wsPort, recordDetail } = this.props; + if (!wsPort) { + return; + } + this.refreshPage(); + + this.wsClient = initWs(wsPort); + this.wsClient.addEventListener('message', this.onMessageHandler); + } + + render() { + const { recordDetail } = this.props; + const { autoRefresh } = this.state; + if (!recordDetail) { + return null; + } + + const playIcon = ; + const pauseIcon = ; + return ( +
this.messageRef = _ref}> +
this.messageContentRef = _ref}> + {this.getMessageList()} +
+
+ {autoRefresh ? pauseIcon : playIcon} +
+
+ ); + } +} + +export default RecordWsMessageDetail; diff --git a/web/src/component/record-ws-message-detail.less b/web/src/component/record-ws-message-detail.less new file mode 100644 index 0000000..13084ae --- /dev/null +++ b/web/src/component/record-ws-message-detail.less @@ -0,0 +1,56 @@ +@import '../style/constant.less'; +.wrapper { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: auto; +} + +.contentWrapper { + overflow: hidden; +} + +.toMessage { + float: right; + clear: both; + margin: 5px auto; + .content { + background-color: @primary-color; + } +} + +.fromMessage { + float: left; + clear: both; + max-width: 40%; + margin: 5px auto; + .content { + background: @success-color; + } +} + +.time { + font-size: @font-size-xs; + color: @tip-color; +} + +.content { + clear: both; + border-radius: @border-radius-base; + color: #fff; + padding: 7px 8px; + font-size: @font-size-sm; + word-wrap: break-word; + word-break: break-all; +} + +.refreshBtn { + position: fixed; + right: 20px; + bottom: 5px; + opacity: 0.53; + font-size: @font-size-large; + cursor: pointer; +} diff --git a/web/src/saga/rootSaga.js b/web/src/saga/rootSaga.js index bf97b96..c4bd376 100644 --- a/web/src/saga/rootSaga.js +++ b/web/src/saga/rootSaga.js @@ -50,6 +50,9 @@ function* doFetchRecordBody(recordId) { // const recordBody = { id: recordId }; yield put(updateFechingRecordStatus(true)); const recordBody = yield call(getJSON, '/fetchBody', { id: recordId }); + if (recordBody.method && recordBody.method.toLowerCase() === 'websocket') { + recordBody.wsMessages = yield call(getJSON, '/fetchWsMessages', { id: recordId}); + } recordBody.id = parseInt(recordBody.id, 10); yield put(updateFechingRecordStatus(false)); diff --git a/web/src/style/common.less b/web/src/style/common.less index 7129495..2cf688f 100644 --- a/web/src/style/common.less +++ b/web/src/style/common.less @@ -54,13 +54,13 @@ body { } :global { - .ant-btn { - min-width: 100px; - } + // .ant-btn { + // min-width: 100px; + // } } .relativeWrapper { position: relative; width: 100%; height: 100%; -} \ No newline at end of file +}