UNPKG

whistle

Version:

HTTP, HTTP2, HTTPS, Websocket debugging proxy

495 lines (476 loc) 15.1 kB
var http = require('http'); var gzip = require('zlib').gzip; var tls = require('tls'); var crypto = require('crypto'); var extend = require('extend'); var common = require('../util/common'); var getSender = require('ws-parser').getSender; var hparser = require('hparser'); var composeData = require('./compose-data'); var Storage = require('../rules/storage'); var dataCenter = require('./data-center'); var formatHeaders = hparser.formatHeaders; var getRawHeaders = hparser.getRawHeaders; var getRawHeaderNames = hparser.getRawHeaderNames; var config = {}; var noop = common.noop; var parseReq = hparser.parse; var MAX_LENGTH = 1024 * 2048; var MAX_REQ_COUNT = 100; var propertiesStorage; var TLS_PROTOS = 'https:,wss:,tls:'.split(','); var PROXY_OPTS = {}; var composerHistory = []; var MAX_HISTORY_LEN = 100; var MAX_URL_LEN = 10 * 1024; var MAX_HEADERS_LEN = 128 * 1024; var MAX_BODY_LEN = 260 * 1024; var MAX_BASE64_LEN = 360 * 1024; var MAX_METHOD_LEN = 64; var LEN_HEADER = 'content-length'; var composerTimer; function saveComposerHistory() { composerTimer = null; try { propertiesStorage.writeFile('composerHistory', JSON.stringify(composerHistory)); } catch (e) {} } function handleComposerHistory(data) { var url = data.url; var method = data.method; var headers = data.headers; var body = data.body; var base64 = data.base64; if (body || !base64 || typeof base64 !== 'string' || base64.length > MAX_BASE64_LEN) { base64 = undefined; } var result = { date: Date.now(), useH2: data.useH2, url: url.length > MAX_URL_LEN ? url.substring(0, MAX_URL_LEN) : url, method: method.length > MAX_METHOD_LEN ? method.substring(0, MAX_METHOD_LEN) : method, headers: headers.length > MAX_HEADERS_LEN ? headers.substring(0, MAX_HEADERS_LEN) : headers, body: body.length > MAX_BODY_LEN ? body.substring(0, MAX_BODY_LEN) : body, isHexText: !!data.isHexText, base64: base64, enableProxyRules: !!data.enableProxyRules }; var dataHash; var base64Hash; for (var i = 0, len = composerHistory.length; i < len; i++) { var item = composerHistory[i]; if ( item.url === result.url && item.method === result.method && item.headers === result.headers && item.body === result.body && item.base64 === result.base64 && !item.useH2 !== result.useH2 && !item.enableProxyRules !== result.enableProxyRules ) { dataHash = item.dataHash; base64Hash = item.base64Hash; composerHistory.splice(i, 1); break; } } result.dataHash = dataHash; result.base64Hash = base64Hash; composerHistory.unshift(result); var overflow = composerHistory.length - MAX_HISTORY_LEN; if (overflow > 0) { composerHistory.splice(MAX_HISTORY_LEN, overflow); } composerTimer = composerTimer || setTimeout(saveComposerHistory, 2000); } function parseHeaders(headers, rawHeaderNames, clientId) { var type = headers && typeof headers; if (type != 'string' && type !== 'object') { return {}; } var reqHeaders = type === 'object' ? headers : common.parseRawJson(headers); if (reqHeaders) { reqHeaders = common.lowerCaseify(reqHeaders, rawHeaderNames); } else { reqHeaders = common.parseHeaders(headers, rawHeaderNames); } if (clientId && reqHeaders[config.CLIENT_ID_HEADER] !== clientId) { reqHeaders[config.COMPOSER_CLIENT_ID_HEADER] = clientId; } return reqHeaders; } function drain(socket) { socket.on('error', noop); socket.on('data', noop); } function getReqCount(count) { return count > 0 ? Math.min(count, MAX_REQ_COUNT) : 1; } function handleConnect(options, cb, count) { count = getReqCount(count); options.headers['x-whistle-policy'] = 'tunnel'; var origOpts = options; var lastIndex = count - 1; for (var i = 0; i < count; i++) { var execCb; if (i === lastIndex) { execCb = cb; } else { options = extend({}, origOpts); } common.connectInner({ host: options.hostname, port: options.port || 443, proxyHost: PROXY_OPTS.host, proxyPort: PROXY_OPTS.port, headers: options.headers }, function(socket, svrRes, err) { if (err) { return execCb && execCb(err); } if (TLS_PROTOS.indexOf(options.protocol) !== -1) { socket = tls.connect({ rejectUnauthorized: config.rejectUnauthorized, socket: socket, servername: options.hostname }); } drain(socket); var data = options.body; if (data && data.length) { socket.write(data); options.body = data = null; } execCb && execCb(null, { statusCode: svrRes.statusCode, headers: svrRes.headers }); }, config).on('error', execCb || noop); } } function getReqRaw(options) { var headers = options.headers; var statusLine = options.method +' ' + (options.path || '/') +' ' + 'HTTP/1.1'; var raw = statusLine; if (headers = getRawHeaders(headers)) { raw += '\r\n' + headers; } return raw + '\r\n\r\n'; } function handleWebSocket(options, cb, count) { count = getReqCount(count); if (options.protocol === 'https:' || options.protocol === 'wss:') { options.headers[common.HTTPS_FIELD] = 1; } var binary = !!options.headers['x-whistle-frame-binary']; delete options.headers['x-whistle-frame-binary']; var origOpts = options; var lastIndex = count - 1; for (var i = 0; i < count; i++) { var execCb; if (i === lastIndex) { execCb = cb; } else { options = extend({}, origOpts); } common.connect(PROXY_OPTS, function(err, socket) { if (err) { execCb && execCb(err); } else { socket.write(getReqRaw(options)); var data = options.body; if ((!data || !data.length) && !cb) { return drain(socket); } parseReq(socket, function(e) { if (e) { socket.destroy(); return execCb && execCb(e); } var statusCode = socket.statusCode; if (statusCode == 101) { if (data) { if (common.isWebSocket(socket.headers)) { getSender(socket).send(data, { mask: true, binary: binary }, noop); } else { socket.write(data); } options.body = data = null; } socket.body = ''; drain(socket); } else { socket.destroy(); } execCb && execCb(null, { statusCode: statusCode, headers: socket.headers || {} }); }, true); } }); } } function handleHttp(options, cb, count, reqId) { count = getReqCount(count); if (options.protocol === 'https:') { options.headers[common.HTTPS_FIELD] = 1; } options.protocol = null; options.hostname = null; options.host = PROXY_OPTS.host; options.port = PROXY_OPTS.port; var origOpts = options; var lastIndex = count - 1; for (var i = 0; i < count; i++) { var execCb; if (i === lastIndex) { execCb = cb; } else { options = extend({}, origOpts); } var client = http.request(options, function(svrRes) { if (!execCb) { return drain(svrRes); } svrRes.on('error', execCb); var buffer; var enableStream; var unzipStream = common.getUnzipStream(svrRes.headers); var timer = reqId && setTimeout(function() { composeData.setData(reqId, buffer, true); enableStream = true; timer = buffer = undefined; handleResponse(); common.onResEnd(svrRes, function() { var buf = composeData.getData(reqId); if (buf) { buf._hasW2End = true; } else { composeData.removeData(reqId); } }); }, 3000); var handleResponse = function() { if (buffer === null) { return; } timer && clearTimeout(timer); var headers = svrRes.headers; if (typeof headers.trailer === 'string' && headers.trailer.indexOf(',') !== -1) { headers.trailer = headers.trailer.split(','); } var result = { statusCode: svrRes.statusCode, headers: headers, trailers: svrRes.trailers, rawHeaderNames: getRawHeaderNames(svrRes.rawHeaders), rawTrailerNames: getRawHeaderNames(svrRes.rawTrailers), reqId: timer ? undefined : reqId }; result.base64 = buffer && buffer.toString('base64'); execCb(null, result); buffer = null; }; if (unzipStream) { unzipStream.on('error', function(err) { drain(svrRes); execCb(err); }); svrRes.pipe(unzipStream); } else { unzipStream = svrRes; } unzipStream.on('data', function(data) { if (enableStream) { if (!composeData.setData(reqId, data)) { enableStream = false; drain(svrRes); svrRes.unpipe(unzipStream); } } else if (buffer !== null) { buffer = buffer ? Buffer.concat([buffer, data]) : data; if (buffer.length > MAX_LENGTH) { handleResponse(); if (unzipStream !== svrRes) { drain(svrRes); svrRes.unpipe(unzipStream); } } } }); unzipStream.on('end', handleResponse); }); client.on('error', execCb || noop); client.end(options.body); options.body = null; } } function getCharset(headers) { var charset = headers && headers['x-whistle-charset']; return charset || common.getCharset(headers['content-type']); } exports.handleRequest = function(req, res) { var fullUrl = req.body.url; if (!fullUrl || typeof fullUrl !== 'string') { return res.json({ec: 0}); } fullUrl = common.encodeNonLatin1Char(fullUrl.replace(/#.*$/, '')); var options = common.parseUrl(common.setProtocol(fullUrl)); if (!options.host) { return res.json({ec: 0}); } var protocol = options.protocol; if (protocol) { options.protocol = protocol = protocol.toLowerCase(); } var rawHeaderNames = {}; var clientId = req.headers[config.CLIENT_ID_HEADER]; var headers = parseHeaders(req.body.headers, rawHeaderNames, clientId); var method = common.getMethod(req.body.method); var isWebSocket = method === 'WEBSOCKET'; delete headers[config.WEBUI_HEAD]; headers[config.FROM_COM_HEADER] = '1'; if (req.body.enableProxyRules === false) { headers[config.DISABLE_RULES_HEADER] = '1'; } headers.host = options.host; options.clientId = clientId; headers[config.CLIENT_IP_HEADER] = req.headers[config.CLIENT_IP_HEADER] || '127.0.0.1'; headers[config.CLIENT_PORT_HEADER] = common.getClientPort(req, config); options.method = method; var isConn = common.isConnect(options); var isWs = !isConn && (isWebSocket || common.isUpgrade(options, headers)); var useH2 = req.body.useH2 || req.body.isH2; req.body.useH2 = false; if (isWs) { headers.connection = 'Upgrade'; headers.upgrade = (!isWebSocket && headers.upgrade) || 'websocket'; headers['sec-websocket-version'] = 13; if (isWebSocket || common.isWebSocket(headers)) { headers['sec-websocket-key'] = crypto.randomBytes(16).toString('base64'); } } else { delete headers.upgrade; if (!isConn && ((useH2 && (protocol === 'https:' || protocol === 'http:')) || protocol === 'h2:' || protocol === 'http2:')) { req.body.useH2 = true; var isHttp = protocol === 'http:'; options.protocol = isHttp ? 'http:' : 'https:'; if (!headers[config.ALPN_PROTOCOL_HEADER]) { headers[config.ALPN_PROTOCOL_HEADER] = (isHttp ? 'httpH2' : 'h2'); } } } if (!req.body.noStore && req.body.needResponse && common.checkHistory(req.body)) { handleComposerHistory(req.body); dataCenter.saveData({ type: 'composer', history: composerHistory }); } var getBody = function(cb) { var base64 = req.body.base64; var body = base64 || req.body.body; if (!isWs) { delete headers.trailer; } if (isWs || isConn || common.hasRequestBody(options)) { body = body && common.toBuffer(body, base64 ? 'base64' : getCharset(headers)); options.body = body; if (!isWs && !isConn && body && req.body.isGzip) { gzip(body, function(err, gzipData) { if (err) { return cb(err); } headers['content-encoding'] = 'gzip'; if (LEN_HEADER in headers) { headers[LEN_HEADER] = gzipData.length; } else { delete headers[LEN_HEADER]; } options.body = gzipData; cb(); }); return; } if (LEN_HEADER in headers) { if (isWs || isConn) { delete headers[LEN_HEADER]; } else { headers[LEN_HEADER] = body ? body.length : '0'; } } } else { delete headers[LEN_HEADER]; } delete headers['content-encoding']; cb(); }; getBody(function(err) { options.headers = formatHeaders(headers, rawHeaderNames); var done; var needResponse = req.query.needResponse || req.body.needResponse; var handleResponse = needResponse ? function(err, data) { if (done) { return; } done = true; if (err) { res.json({ec: 0, res: { statusCode: err.statusCode ? parseInt(err.statusCode, 10) : 502, headers: '', body: err.stack }}); return; } common.sendGzip(req, res, {ec: 0, em: 'success', res: data || ''}); } : null; if (err) { return handleResponse && handleResponse(err); } var count = req.body.repeatCount; count = count > 0 ? count : req.body.repeatTimes; if (isWs) { options.method = 'GET'; handleWebSocket(options, handleResponse, count); } else if (isConn) { handleConnect(options, handleResponse, count); } else { handleHttp(options, handleResponse, count, req.body.reqId); } if (!handleResponse) { res.json({ec: 0, em: 'success'}); } }); }; exports.getHistory = function(req, res) { common.sendGzip(req, res, composerHistory); }; exports.setup = function(conf) { config = conf; PROXY_OPTS = { host: config.host || '127.0.0.1', port: config.port }; propertiesStorage = new Storage(config.propertiesDir, null, false, { composerHistory: true }); var history = propertiesStorage.readFile('composerHistory'); try { composerHistory = JSON.parse(history); } catch (e) {} if (Array.isArray(composerHistory)) { composerHistory = composerHistory.filter(common.checkHistory); composerHistory = composerHistory.slice(0, MAX_HISTORY_LEN); } else { composerHistory = []; } dataCenter.saveComposeData(composerHistory); };