diff --git a/package-lock.json b/package-lock.json index c2a3513..d5b6175 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,12 +5,12 @@ "requires": true, "dependencies": { "@types/body-parser": { - "version": "1.16.4", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.16.4.tgz", - "integrity": "sha512-y8GxleWZ4ep0GG9IFMg+HpZWqLPjAjqc65cAopXPAWONWGCWGT0FCPVlXbUEBOPWpYtFrvlp2D7EJJnrqLUnEQ==", + "version": "1.16.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.16.5.tgz", + "integrity": "sha512-iGYVwwFznpBzqwS3QOoaop3mEbW8vAuqnPN2/pi2/HTREHeFTT+xX4hp5OImpQ7q4bE3a96JsDgBUccsx0fL/A==", "requires": { - "@types/express": "4.0.36", - "@types/node": "8.0.22" + "@types/express": "4.0.37", + "@types/node": "8.0.24" } }, "@types/debug": { @@ -19,33 +19,33 @@ "integrity": "sha1-oeUUrfvZLwOiJLpU1pMRHb8fN1Q=" }, "@types/express": { - "version": "4.0.36", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.0.36.tgz", - "integrity": "sha512-bT9q2eqH/E72AGBQKT50dh6AXzheTqigGZ1GwDiwmx7vfHff0bZOrvUWjvGpNWPNkRmX1vDF6wonG6rlpBHb1A==", + "version": "4.0.37", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.0.37.tgz", + "integrity": "sha512-tIULTLzQpFFs5/PKnFIAFOsXQxss76glppbVKR3/jddPK26SBsD5HF5grn5G2jOGtpRWSBvYmDYoduVv+3wOXg==", "requires": { - "@types/express-serve-static-core": "4.0.49", - "@types/serve-static": "1.7.31" + "@types/express-serve-static-core": "4.0.50", + "@types/serve-static": "1.7.32" } }, "@types/express-serve-static-core": { - "version": "4.0.49", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.0.49.tgz", - "integrity": "sha512-b7mVHoURu1xaP/V6xw1sYwyv9V0EZ7euyi+sdnbnTZxEkAh4/hzPsI6Eflq+ZzHQ/Tgl7l16Jz+0oz8F46MLnA==", + "version": "4.0.50", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.0.50.tgz", + "integrity": "sha512-0n1YgeUfZEIaMMu82LuOFIFDyMtFtcEP0yjQKihJlNjpCiygDVri7C26DC7jaUOwFXL6ZU2x4tGtNYNEgeO3tw==", "requires": { - "@types/node": "8.0.22" + "@types/node": "8.0.24" } }, "@types/lodash": { - "version": "4.14.73", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.73.tgz", - "integrity": "sha512-wZDQC6C2VlCaddkd57b363vLL8J6zcwMqP5jqR4Aikcfh85FmPTINrSCWXZHG9JlkQ07ojeNNt71EyccfIdnKQ==" + "version": "4.14.74", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.74.tgz", + "integrity": "sha512-BZknw3E/z3JmCLqQVANcR17okqVTPZdlxvcIz0fJiJVLUCbSH1hK3zs9r634PVSmrzAxN+n/fxlVRiYoArdOIQ==" }, "@types/lodash.clonedeep": { "version": "4.5.3", "resolved": "https://registry.npmjs.org/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.3.tgz", "integrity": "sha512-307uNXpe90TSLRCZM2ggGtdzKZ2h4v1tDMDsahYI4/CvWd1+VhAmRQ+WJyA+wLF6kLLUae9JhpdKyLAzRaW3Qw==", "requires": { - "@types/lodash": "4.14.73" + "@types/lodash": "4.14.74" } }, "@types/mime": { @@ -54,17 +54,17 @@ "integrity": "sha512-rek8twk9C58gHYqIrUlJsx8NQMhlxqHzln9Z9ODqiNgv3/s+ZwIrfr+djqzsnVM12xe9hL98iJ20lj2RvCBv6A==" }, "@types/mkdirp": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@types/mkdirp/-/mkdirp-0.5.0.tgz", - "integrity": "sha512-9UvtpVx/f9ly3T0bTri3DNQYyRWoJ2CPwvBKCeD0BOG41XQBVCx4wr1aKcdOv3Uv+oeqJoFRrgAOxxO3hrFg5g==", + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@types/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha512-XA4vNO6GCBz8Smq0hqSRo4yRWMqr4FPQrWjhJt6nKskzly4/p87SfuJMFYGRyYb6jo2WNIQU2FDBsY5r1BibUA==", "requires": { - "@types/node": "8.0.22" + "@types/node": "8.0.24" } }, "@types/node": { - "version": "8.0.22", - "resolved": "https://registry.npmjs.org/@types/node/-/node-8.0.22.tgz", - "integrity": "sha512-+YQ5JLlvLP24teVUdUDep83mAWIFoAnOMosrH/2+xDeU9YMUpmMJtYOqVtbivs37h2PL9svz0R3r/MfVuEvEIA==" + "version": "8.0.24", + "resolved": "https://registry.npmjs.org/@types/node/-/node-8.0.24.tgz", + "integrity": "sha512-c3Npme+2JGqxW8+B+aXdN5SPIlCf1C8WxQC6Ea39rO/ASPosnMkWVR16mDJtRE+2dr2xwOQ7DiLxb+wO/TWuPg==" }, "@types/normalize-url": { "version": "1.9.1", @@ -72,20 +72,20 @@ "integrity": "sha512-NWKCFU+yFaTY4yY1qNiAnlb085k2ZUKbgJ/ViZka13T90uQ7e17htntVOE5y2RcnTpoHvjMp4hyhZoU0mDdSlA==" }, "@types/serve-static": { - "version": "1.7.31", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.7.31.tgz", - "integrity": "sha1-FUVt6NmNa0z/Mb5savdJKuY/Uho=", + "version": "1.7.32", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.7.32.tgz", + "integrity": "sha512-WpI0g7M1FiOmJ/a97Qrjafq2I938tjAZ3hZr9O7sXyA6oUhH3bqUNZIt7r1KZg8TQAKxcvxt6JjQ5XuLfIBFvg==", "requires": { - "@types/express-serve-static-core": "4.0.49", + "@types/express-serve-static-core": "4.0.50", "@types/mime": "1.3.1" } }, "@types/uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-Vd+WmnrQKrrfVJ+9LWyOWqlBQJFsfi8rhKRm3ag3ZrOjY5SmzZkGmxbkgRIk9jpZt4dpvE21cmbBSp1dCV7/fw==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-3.4.1.tgz", + "integrity": "sha512-+4JoeLK6m/etwfAvulsw7pyCkzVZG48H+FkwM2MdL4jMq+2QC6aWPZcKHJhYr89dW6cOiL/MqZExAK4AzoCchg==", "requires": { - "@types/node": "8.0.22" + "@types/node": "8.0.24" } }, "@types/yargs": { @@ -94,9 +94,9 @@ "integrity": "sha512-Upj9YsBZRgjEVPvsaeGru48d2JiyzBNZkmkebHyoaQ+UM9wqj/rp5mkilRjSq/Ga45yfd/zwrNuML9f2gGfVpw==" }, "accepts": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.3.tgz", - "integrity": "sha1-w8p0NJOGSMPg2cHjKN1otiLChMo=", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.4.tgz", + "integrity": "sha1-hiRnWMfdbSGmR0/whKR0DsBesh8=", "requires": { "mime-types": "2.1.16", "negotiator": "0.6.1" @@ -190,9 +190,9 @@ "dev": true }, "babel-code-frame": { - "version": "6.22.0", - "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.22.0.tgz", - "integrity": "sha1-AnYgvuVnqIwyVhV05/0IAdMxGOQ=", + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", + "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=", "dev": true, "requires": { "chalk": "1.1.3", @@ -631,7 +631,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.15.4.tgz", "integrity": "sha1-Ay4iU0ic+PzgJma+yj0R7XotrtE=", "requires": { - "accepts": "1.3.3", + "accepts": "1.3.4", "array-flatten": "1.1.1", "content-disposition": "0.5.2", "content-type": "1.0.2", @@ -1382,9 +1382,9 @@ } }, "pkg": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/pkg/-/pkg-4.2.3.tgz", - "integrity": "sha512-OeNTi6U2vUPBXkEctObaGL6muWki6WgohPlTR/Ob7DRv1Beoc/pWaigCgzQ2LSC7vYtIjQKncSet4ljx+LqUEg==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/pkg/-/pkg-4.2.4.tgz", + "integrity": "sha512-v9WZ3B7mjswOf+VIX0gIfU2DsH68dz/tQt6+AOg+6Q700UyFTK3q2cDFN1BZEkGOM2mZxlhbsXnYGWPZzS9lvQ==", "dev": true, "requires": { "acorn": "5.1.1", @@ -1909,7 +1909,7 @@ "integrity": "sha1-CIqmxgJmIzOGULKQCCirPt9Z9s8=", "dev": true, "requires": { - "babel-code-frame": "6.22.0", + "babel-code-frame": "6.26.0", "colors": "1.1.2", "commander": "2.11.0", "diff": "3.3.0", @@ -1918,13 +1918,13 @@ "resolve": "1.4.0", "semver": "5.4.1", "tslib": "1.7.1", - "tsutils": "2.8.0" + "tsutils": "2.8.1" } }, "tsutils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-2.8.0.tgz", - "integrity": "sha1-AWAXNymzvxOGKN0UoVN+AIUdgUo=", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-2.8.1.tgz", + "integrity": "sha1-N3FATnyp8L7fXZGaR6SxiQpo7/8=", "dev": true, "requires": { "tslib": "1.7.1" diff --git a/src/common.ts b/src/common.ts index 3f661bc..4bd5c85 100644 --- a/src/common.ts +++ b/src/common.ts @@ -1,4 +1,4 @@ -import { IncomingHttpHeaders, request as httpReqFn } from 'http'; +import { IncomingHttpHeaders, OutgoingHttpHeaders, request as httpReqFn } from 'http'; import { request as httpsReqFn } from 'https'; import cloneDeep = require('lodash.clonedeep'); import { ResponseInfo } from 'steno'; @@ -23,10 +23,25 @@ export function requestFunctionForTargetUrl(url: Url) { return httpReqFn; } -export function fixRequestHeaders(targetUrl: Url, headers: IncomingHttpHeaders): IncomingHttpHeaders { - const headersCopy = cloneDeep(headers); - if (headersCopy.host) { - headersCopy.host = (targetUrl.host as string); +export function fixRequestHeaders(hostname?: string, headers?: OutgoingHttpHeaders): IncomingHttpHeaders { + if (!headers) { + return {}; + } + const headersCopy: IncomingHttpHeaders = {}; + Object.keys(headers).forEach((key) => { + const val = headers[key]; + if (val !== undefined) { + if (Array.isArray(val)) { + headersCopy[key] = val.slice(); + } else if (typeof val === 'number') { + headersCopy[key] = '' + val; + } else { + headersCopy[key] = val.slice(0); + } + } + }); + if (hostname && headersCopy.host) { + headersCopy.host = hostname; } return headersCopy; } diff --git a/src/record/controller.ts b/src/record/controller.ts index ac46a76..9cf2a39 100644 --- a/src/record/controller.ts +++ b/src/record/controller.ts @@ -6,6 +6,7 @@ import normalizePort = require('normalize-port'); import normalizeUrl = require('normalize-url'); import { join as pathJoin } from 'path'; import { PrintFn } from 'steno'; +import { ProxyTargetConfig, ProxyTargetRule } from './http-proxy'; import { Recorder } from './recorder'; const log = Debug('steno:recordingcontroller'); @@ -22,10 +23,10 @@ export class RecordingController { private recorder: Recorder; private print: PrintFn; - constructor(incomingTargetUrl: string, outgoingTargetUrl: string, controlPort: string, + constructor(incomingTargetConfig: ProxyTargetConfig, outgoingTargetConfig: ProxyTargetConfig, controlPort: string, inPort: string, outPort: string, scenarioName: string, print: PrintFn) { this.scenarioName = scenarioName; - this.recorder = new Recorder(outgoingTargetUrl, outPort, incomingTargetUrl, inPort, + this.recorder = new Recorder(outgoingTargetConfig, outPort, incomingTargetConfig, inPort, pathFromScenarioName(this.scenarioName), print); this.app = this.createApp(); this.port = controlPort; @@ -101,9 +102,30 @@ export function startRecordingController( const outTargetHost = `${ outHostPrefix }slack.com`; const outTargetUrl = `https://${ outTargetHost }`; + const incomingWebhooksPathPattern = /^\/services\//; + const slashCommandsPathPattern = /^\/commands\//; + const interactiveResponseUrlPathPattern = /^\/actions\//; + const hooksSubdomainRewriteRule: ProxyTargetRule = { + processor: (req, optsBefore) => { + if (req.url && + (incomingWebhooksPathPattern.test(req.url) || slashCommandsPathPattern.test(req.url) || + interactiveResponseUrlPathPattern.test(req.url)) + ) { + return Object.assign({}, optsBefore, { + // remove host because apparently hostname is preffered. host would have to include port, so meh. + host: null, + hostname: `hooks.${outTargetHost}`, + }); + } + return optsBefore; + }, + type: 'requestOptionRewrite', + }; + const controller = new RecordingController( - normalizeUrl(incomingRequestTargetUrl), outTargetUrl, normalizePort(controlPort), normalizePort(inPort), - normalizePort(outPort), scenarioName, print, + { targetUrl: normalizeUrl(incomingRequestTargetUrl) }, + { targetUrl: outTargetUrl, rules: [hooksSubdomainRewriteRule] }, + normalizePort(controlPort), normalizePort(inPort), normalizePort(outPort), scenarioName, print, ); return controller.start(); } diff --git a/src/record/http-proxy.ts b/src/record/http-proxy.ts index db96616..c45ccb5 100644 --- a/src/record/http-proxy.ts +++ b/src/record/http-proxy.ts @@ -11,29 +11,44 @@ import { fixRequestHeaders, requestFunctionForTargetUrl } from '../common'; import { RequestInfo, ResponseInfo } from 'steno'; +export interface ProxyTargetRule { + type: 'requestOptionRewrite'; + // NOTE: perhaps instead of exposing the original request to the rule processor, we should just expose the parsed + // RequestInfo + processor: (originalReq: IncomingMessage, reqOptions: RequestOptions) => RequestOptions; +} + +export interface ProxyTargetConfig { + rules?: ProxyTargetRule[]; + targetUrl: string; // stores a URL after it's passed through normalizeUrl() +} + const log = Debug('steno:http-proxy'); export class HttpProxy extends EventEmitter { private server: Server; private targetUrl: Url; + private requestOptionRewriteRules?: ProxyTargetRule[]; private requestFn: (options: RequestOptions | string | URL, callback?: (res: IncomingMessage) => void) => ClientRequest; - constructor(targetUrl: string) { + constructor(targetConfig: ProxyTargetConfig) { super(); - log(`proxy init with target URL: ${targetUrl}`); - this.targetUrl = urlParse(targetUrl); + log(`proxy init with target URL: ${targetConfig.targetUrl}`); + this.targetUrl = urlParse(targetConfig.targetUrl); + if (targetConfig.rules) { + this.requestOptionRewriteRules = targetConfig.rules.filter((r) => r.type === 'requestOptionRewrite'); + } this.requestFn = requestFunctionForTargetUrl(this.targetUrl); this.server = createServer(HttpProxy.prototype.onRequest.bind(this)); } public onRequest(req: IncomingMessage, res: ServerResponse) { // NOTE: cloneDeep usage here is for safety, but if this is a performance hit, we likely could remove it - const mungedHeaders = fixRequestHeaders(this.targetUrl, req.headers); const requestInfo: RequestInfo = { body: undefined, - headers: mungedHeaders, + headers: req.headers, httpVersion: req.httpVersion, id: uuid(), method: cloneDeep(req.method as string), @@ -41,11 +56,21 @@ export class HttpProxy extends EventEmitter { url: cloneDeep(req.url as string), }; - const proxyReqOptions = Object.assign({}, this.targetUrl, { + let proxyReqOptions: RequestOptions = Object.assign({}, this.targetUrl, { headers: requestInfo.headers, + href: null, method: requestInfo.method, path: requestInfo.url, }); + + if (this.requestOptionRewriteRules) { + // iteratively apply any rules + proxyReqOptions = this.requestOptionRewriteRules + .reduce((options, rule) => rule.processor(req, options), proxyReqOptions); + } + + proxyReqOptions.headers = fixRequestHeaders(proxyReqOptions.hostname, proxyReqOptions.headers); + log('creating proxy request with options: %O', proxyReqOptions); const proxyRequest = this.requestFn(proxyReqOptions); // TODO: are response trailers really set on `req`? @@ -124,6 +149,6 @@ export class HttpProxy extends EventEmitter { } -export function createProxy(targetUrl: string): HttpProxy { - return new HttpProxy(targetUrl); +export function createProxy(targetConfig: ProxyTargetConfig): HttpProxy { + return new HttpProxy(targetConfig); } diff --git a/src/record/recorder.ts b/src/record/recorder.ts index 3e7a5f8..76e2b76 100644 --- a/src/record/recorder.ts +++ b/src/record/recorder.ts @@ -1,7 +1,7 @@ import Debug = require('debug'); import { PrintFn } from 'steno'; -import { createProxy, HttpProxy } from './http-proxy'; +import { createProxy, HttpProxy, ProxyTargetConfig } from './http-proxy'; import { HttpSerializer } from './http-serializer'; const log = Debug('steno:recorder'); @@ -18,16 +18,16 @@ export class Recorder { private incomingPort: string | number; private print: PrintFn; - constructor(outgoingTargetUrl: string, outgoingPort: string | number, - incomingTargetUrl: string, incomingPort: string | number, + constructor(outgoingTargetConfig: ProxyTargetConfig, outgoingPort: string | number, + incomingTargetConfig: ProxyTargetConfig, incomingPort: string | number, storagePath: string, print: PrintFn) { this.serializer = new HttpSerializer(storagePath); - this.outgoingProxy = createProxy(outgoingTargetUrl); + this.outgoingProxy = createProxy(outgoingTargetConfig); this.outgoingPort = outgoingPort; - this.incomingProxy = createProxy(incomingTargetUrl); - this.incomingTargetUrl = incomingTargetUrl; + this.incomingProxy = createProxy(incomingTargetConfig); + this.incomingTargetUrl = incomingTargetConfig.targetUrl; this.incomingPort = incomingPort; this.outgoingProxy.on('request', (info) => { this.serializer.onRequest(info, `${Date.now()}_outgoing`); });