From bb0976849463819893e5b924dc4af7ec31cfd25d Mon Sep 17 00:00:00 2001 From: Shubh Bapna <38372682+shubhbapna@users.noreply.github.com> Date: Tue, 15 Aug 2023 08:56:23 -0400 Subject: [PATCH] feat: handle `CONNECT` requests for http destinations (#50) * handle clients which send a connect request for http destinations * if verbose is set for act, then set verbose for ForwardProxy as well * fixed tests to actually test mocking via ForwardProxy. added test for http connect requests * updated package.json and package-lock.json files * updated docs * fix tests by passing parents env variables to child so that node is accessible --- README.md | 11 +- package-lock.json | 336 ++++++++++++++++++++++++++++++---- package.json | 1 + src/act/act.ts | 2 +- src/proxy/proxy.ts | 88 +++++++-- src/proxy/proxy.types.ts | 1 + test/unit/proxy/proxy.test.ts | 213 +++++++++++++++------ 7 files changed, 548 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 60c6fb2..b9d2ab0 100644 --- a/README.md +++ b/README.md @@ -285,13 +285,12 @@ let result = await act.runEventAndJob("pull_request", "jobId"); #### Mocking apis during the run -You can use [Mockapi](#mockapi) and [Moctokit](https://github.com/kiegroup/mock-github#moctokit) to mock any kind of HTTP and HTTPS requests during your workflow run provided that the client being used honours HTTP_PROXY and HTTPS_PROXY env variables. Depending on the client, for HTTPS they might issue a CONNECT request to open a secure TCP tunnel. In this case `Act` won't be able to mock the HTTPS request. -(Note - For Octokit, you can mock HTTPS requests because it does not issues a CONNECT request) +You can use [Mockapi](#mockapi) and [Moctokit](https://github.com/kiegroup/mock-github#moctokit) to mock any kind of HTTP and HTTPS requests during your workflow run provided that the client being used honors HTTP_PROXY and HTTPS_PROXY env variables. Depending on the client, for HTTPS they might issue a CONNECT request to open a secure TCP tunnel. In this case `Act` won't be able to mock the HTTPS request. ```typescript import { Moctokit } from "@kie/mock-github"; import { Mockapi } from "@kie/act-js"; -const moctokit = new Moctokit(); +const moctokit = new Moctokit("http://api.github.com"); const mockapi = new Mockapi({ customApi: { baseUrl: "http://custom-api.com", @@ -326,6 +325,12 @@ let result = await act.runEvent("pull_request", { }); ``` +For testing actions which use `Octokit`, you will need to make sure that `Octokit` instance is configured to use proxies. You can do so by using [ProxyAgent](https://github.com/octokit/octokit.js#proxy-servers-nodejs-only) or using the hydrated `Octokit` instance from [@actions/github](https://github.com/actions/toolkit/tree/main/packages/github). + +Examples to help you get started: +- [Using ProxyAgent with Octokit](https://github.com/shubhbapna/mock-github-act-js-examples/tree/main/custom-actions/javascript) +- [Testing @actions/github-script which uses @actions/github internally](https://github.com/shubhbapna/mock-github-act-js-examples/tree/main/workflow/github-script) + #### Mocking steps There are cases where some of the steps have to be directly skipped or mocked because it is not feasible to execute them in a test env and might even be redundant to test them (npm publish for instance), so `mockSteps` mechanism is provided to overcome those cases. diff --git a/package-lock.json b/package-lock.json index 3b8de31..78ed622 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "act-js": "bin/act" }, "devDependencies": { + "@actions/github": "^5.1.1", "@octokit/rest": "^19.0.5", "@types/express": "^4.17.13", "@types/follow-redirects": "^1.14.1", @@ -45,6 +46,138 @@ "typescript": "^4.7.4" } }, + "node_modules/@actions/github": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@actions/github/-/github-5.1.1.tgz", + "integrity": "sha512-Nk59rMDoJaV+mHCOJPXuvB1zIbomlKS0dmSIqPGxd0enAXBnOfn4VWF+CGtRCwXZG9Epa54tZA7VIRlJDS8A6g==", + "dev": true, + "dependencies": { + "@actions/http-client": "^2.0.1", + "@octokit/core": "^3.6.0", + "@octokit/plugin-paginate-rest": "^2.17.0", + "@octokit/plugin-rest-endpoint-methods": "^5.13.0" + } + }, + "node_modules/@actions/github/node_modules/@octokit/auth-token": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.5.0.tgz", + "integrity": "sha512-r5FVUJCOLl19AxiuZD2VRZ/ORjp/4IN98Of6YJoJOkY75CIBuYfmiNHGrDwXr+aLGG55igl9QrxX3hbiXlLb+g==", + "dev": true, + "dependencies": { + "@octokit/types": "^6.0.3" + } + }, + "node_modules/@actions/github/node_modules/@octokit/core": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-3.6.0.tgz", + "integrity": "sha512-7RKRKuA4xTjMhY+eG3jthb3hlZCsOwg3rztWh75Xc+ShDWOfDDATWbeZpAHBNRpm4Tv9WgBMOy1zEJYXG6NJ7Q==", + "dev": true, + "dependencies": { + "@octokit/auth-token": "^2.4.4", + "@octokit/graphql": "^4.5.8", + "@octokit/request": "^5.6.3", + "@octokit/request-error": "^2.0.5", + "@octokit/types": "^6.0.3", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@actions/github/node_modules/@octokit/endpoint": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.12.tgz", + "integrity": "sha512-lF3puPwkQWGfkMClXb4k/eUT/nZKQfxinRWJrdZaJO85Dqwo/G0yOC434Jr2ojwafWJMYqFGFa5ms4jJUgujdA==", + "dev": true, + "dependencies": { + "@octokit/types": "^6.0.3", + "is-plain-object": "^5.0.0", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@actions/github/node_modules/@octokit/graphql": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.8.0.tgz", + "integrity": "sha512-0gv+qLSBLKF0z8TKaSKTsS39scVKF9dbMxJpj3U0vC7wjNWFuIpL/z76Qe2fiuCbDRcJSavkXsVtMS6/dtQQsg==", + "dev": true, + "dependencies": { + "@octokit/request": "^5.6.0", + "@octokit/types": "^6.0.3", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@actions/github/node_modules/@octokit/openapi-types": { + "version": "12.11.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz", + "integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==", + "dev": true + }, + "node_modules/@actions/github/node_modules/@octokit/plugin-paginate-rest": { + "version": "2.21.3", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.21.3.tgz", + "integrity": "sha512-aCZTEf0y2h3OLbrgKkrfFdjRL6eSOo8komneVQJnYecAxIej7Bafor2xhuDJOIFau4pk0i/P28/XgtbyPF0ZHw==", + "dev": true, + "dependencies": { + "@octokit/types": "^6.40.0" + }, + "peerDependencies": { + "@octokit/core": ">=2" + } + }, + "node_modules/@actions/github/node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "5.16.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-5.16.2.tgz", + "integrity": "sha512-8QFz29Fg5jDuTPXVtey05BLm7OB+M8fnvE64RNegzX7U+5NUXcOcnpTIK0YfSHBg8gYd0oxIq3IZTe9SfPZiRw==", + "dev": true, + "dependencies": { + "@octokit/types": "^6.39.0", + "deprecation": "^2.3.1" + }, + "peerDependencies": { + "@octokit/core": ">=3" + } + }, + "node_modules/@actions/github/node_modules/@octokit/request": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.6.3.tgz", + "integrity": "sha512-bFJl0I1KVc9jYTe9tdGGpAMPy32dLBXXo1dS/YwSCTL/2nd9XeHsY616RE3HPXDVk+a+dBuzyz5YdlXwcDTr2A==", + "dev": true, + "dependencies": { + "@octokit/endpoint": "^6.0.1", + "@octokit/request-error": "^2.1.0", + "@octokit/types": "^6.16.1", + "is-plain-object": "^5.0.0", + "node-fetch": "^2.6.7", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@actions/github/node_modules/@octokit/request-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.1.0.tgz", + "integrity": "sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==", + "dev": true, + "dependencies": { + "@octokit/types": "^6.0.3", + "deprecation": "^2.0.0", + "once": "^1.4.0" + } + }, + "node_modules/@actions/github/node_modules/@octokit/types": { + "version": "6.41.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz", + "integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==", + "dev": true, + "dependencies": { + "@octokit/openapi-types": "^12.11.0" + } + }, + "node_modules/@actions/http-client": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.1.0.tgz", + "integrity": "sha512-BonhODnXr3amchh4qkmjPMUO8mFi/zLaaCeCAJZqch8iQqyDnVIkySjB38VHAC8IJ+bnlgfOqlhpyCUZHlQsqw==", + "dev": true, + "dependencies": { + "tunnel": "^0.0.6" + } + }, "node_modules/@ampproject/remapping": { "version": "2.2.0", "dev": true, @@ -1281,26 +1414,6 @@ "node": ">= 14" } }, - "node_modules/@octokit/request/node_modules/node-fetch": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz", - "integrity": "sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==", - "dev": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/@octokit/rest": { "version": "19.0.5", "dev": true, @@ -5935,6 +6048,26 @@ "lodash": "^4.17.21" } }, + "node_modules/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", + "dev": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-int64": { "version": "0.4.0", "dev": true, @@ -10551,6 +10684,15 @@ "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" } }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "dev": true, @@ -10899,6 +11041,134 @@ } }, "dependencies": { + "@actions/github": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@actions/github/-/github-5.1.1.tgz", + "integrity": "sha512-Nk59rMDoJaV+mHCOJPXuvB1zIbomlKS0dmSIqPGxd0enAXBnOfn4VWF+CGtRCwXZG9Epa54tZA7VIRlJDS8A6g==", + "dev": true, + "requires": { + "@actions/http-client": "^2.0.1", + "@octokit/core": "^3.6.0", + "@octokit/plugin-paginate-rest": "^2.17.0", + "@octokit/plugin-rest-endpoint-methods": "^5.13.0" + }, + "dependencies": { + "@octokit/auth-token": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.5.0.tgz", + "integrity": "sha512-r5FVUJCOLl19AxiuZD2VRZ/ORjp/4IN98Of6YJoJOkY75CIBuYfmiNHGrDwXr+aLGG55igl9QrxX3hbiXlLb+g==", + "dev": true, + "requires": { + "@octokit/types": "^6.0.3" + } + }, + "@octokit/core": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-3.6.0.tgz", + "integrity": "sha512-7RKRKuA4xTjMhY+eG3jthb3hlZCsOwg3rztWh75Xc+ShDWOfDDATWbeZpAHBNRpm4Tv9WgBMOy1zEJYXG6NJ7Q==", + "dev": true, + "requires": { + "@octokit/auth-token": "^2.4.4", + "@octokit/graphql": "^4.5.8", + "@octokit/request": "^5.6.3", + "@octokit/request-error": "^2.0.5", + "@octokit/types": "^6.0.3", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + } + }, + "@octokit/endpoint": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.12.tgz", + "integrity": "sha512-lF3puPwkQWGfkMClXb4k/eUT/nZKQfxinRWJrdZaJO85Dqwo/G0yOC434Jr2ojwafWJMYqFGFa5ms4jJUgujdA==", + "dev": true, + "requires": { + "@octokit/types": "^6.0.3", + "is-plain-object": "^5.0.0", + "universal-user-agent": "^6.0.0" + } + }, + "@octokit/graphql": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.8.0.tgz", + "integrity": "sha512-0gv+qLSBLKF0z8TKaSKTsS39scVKF9dbMxJpj3U0vC7wjNWFuIpL/z76Qe2fiuCbDRcJSavkXsVtMS6/dtQQsg==", + "dev": true, + "requires": { + "@octokit/request": "^5.6.0", + "@octokit/types": "^6.0.3", + "universal-user-agent": "^6.0.0" + } + }, + "@octokit/openapi-types": { + "version": "12.11.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz", + "integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==", + "dev": true + }, + "@octokit/plugin-paginate-rest": { + "version": "2.21.3", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.21.3.tgz", + "integrity": "sha512-aCZTEf0y2h3OLbrgKkrfFdjRL6eSOo8komneVQJnYecAxIej7Bafor2xhuDJOIFau4pk0i/P28/XgtbyPF0ZHw==", + "dev": true, + "requires": { + "@octokit/types": "^6.40.0" + } + }, + "@octokit/plugin-rest-endpoint-methods": { + "version": "5.16.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-5.16.2.tgz", + "integrity": "sha512-8QFz29Fg5jDuTPXVtey05BLm7OB+M8fnvE64RNegzX7U+5NUXcOcnpTIK0YfSHBg8gYd0oxIq3IZTe9SfPZiRw==", + "dev": true, + "requires": { + "@octokit/types": "^6.39.0", + "deprecation": "^2.3.1" + } + }, + "@octokit/request": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.6.3.tgz", + "integrity": "sha512-bFJl0I1KVc9jYTe9tdGGpAMPy32dLBXXo1dS/YwSCTL/2nd9XeHsY616RE3HPXDVk+a+dBuzyz5YdlXwcDTr2A==", + "dev": true, + "requires": { + "@octokit/endpoint": "^6.0.1", + "@octokit/request-error": "^2.1.0", + "@octokit/types": "^6.16.1", + "is-plain-object": "^5.0.0", + "node-fetch": "^2.6.7", + "universal-user-agent": "^6.0.0" + } + }, + "@octokit/request-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.1.0.tgz", + "integrity": "sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==", + "dev": true, + "requires": { + "@octokit/types": "^6.0.3", + "deprecation": "^2.0.0", + "once": "^1.4.0" + } + }, + "@octokit/types": { + "version": "6.41.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz", + "integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==", + "dev": true, + "requires": { + "@octokit/openapi-types": "^12.11.0" + } + } + } + }, + "@actions/http-client": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.1.0.tgz", + "integrity": "sha512-BonhODnXr3amchh4qkmjPMUO8mFi/zLaaCeCAJZqch8iQqyDnVIkySjB38VHAC8IJ+bnlgfOqlhpyCUZHlQsqw==", + "dev": true, + "requires": { + "tunnel": "^0.0.6" + } + }, "@ampproject/remapping": { "version": "2.2.0", "dev": true, @@ -11730,17 +12000,6 @@ "is-plain-object": "^5.0.0", "node-fetch": "^2.6.7", "universal-user-agent": "^6.0.0" - }, - "dependencies": { - "node-fetch": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz", - "integrity": "sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==", - "dev": true, - "requires": { - "whatwg-url": "^5.0.0" - } - } } }, "@octokit/request-error": { @@ -14841,6 +15100,15 @@ "lodash": "^4.17.21" } }, + "node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", + "dev": true, + "requires": { + "whatwg-url": "^5.0.0" + } + }, "node-int64": { "version": "0.4.0", "dev": true @@ -18001,6 +18269,12 @@ "tslib": "^1.8.1" } }, + "tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true + }, "type-check": { "version": "0.4.0", "dev": true, diff --git a/package.json b/package.json index 9cfd711..13cc2fe 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "author": "Shubh Bapna ", "license": "SEE LICENSE IN LICENSE", "devDependencies": { + "@actions/github": "^5.1.1", "@octokit/rest": "^19.0.5", "@types/express": "^4.17.13", "@types/follow-redirects": "^1.14.1", diff --git a/src/act/act.ts b/src/act/act.ts index bda5ea5..f489758 100644 --- a/src/act/act.ts +++ b/src/act/act.ts @@ -289,7 +289,7 @@ export class Act { let proxy: ForwardProxy | undefined = undefined; if (opts?.mockApi && opts.mockApi.length > 0) { - proxy = new ForwardProxy(opts.mockApi); + proxy = new ForwardProxy(opts.mockApi, opts?.verbose); const address = await proxy.start(); diff --git a/src/proxy/proxy.ts b/src/proxy/proxy.ts index 8043c52..a003adc 100644 --- a/src/proxy/proxy.ts +++ b/src/proxy/proxy.ts @@ -5,6 +5,7 @@ import { Server } from "http"; import { ResponseMocker } from "@kie/mock-github"; import { networkInterfaces } from "os"; import internal from "stream"; +import { LOCALHOST } from "@aj/proxy/proxy.types"; export class ForwardProxy { private apis: ResponseMocker[]; @@ -13,14 +14,20 @@ export class ForwardProxy { private logger: (msg: string) => void; private currentConnections: Record; private currentSocketId: number; + private secondaryProxy?: { + proxy: ForwardProxy, + proxyAddress: string + }; + private shouldHandleConnect: boolean; - constructor(apis: ResponseMocker[], verbose = false) { + constructor(apis: ResponseMocker[], verbose = false, shouldHandleConnect = true) { this.apis = apis; this.app = express(); - this.logger = verbose ? console.log : _msg => undefined; + this.logger = verbose ? console.log.bind(undefined, "[act-js API Mocker]") : _msg => undefined; this.server = http.createServer(this.app); this.currentConnections = {}; this.currentSocketId = 0; + this.shouldHandleConnect = shouldHandleConnect; } /** @@ -66,7 +73,12 @@ export class ForwardProxy { Object.values(this.currentConnections).forEach(socket => socket.destroy() ); - resolve(); + + if (this.secondaryProxy) { + this.secondaryProxy.proxy.stop().then(resolve); + } else { + resolve(); + } } }); } @@ -92,9 +104,6 @@ export class ForwardProxy { } private initServer() { - // this context is lost in callbacks - const logger = this.logger; - // keep track of connected sockets for clean up this.server.on("connection", socket => { const socketId = this.currentSocketId; @@ -104,44 +113,89 @@ export class ForwardProxy { this.currentSocketId += 1; }); - // forward any https connections intiated via CONNECT as is - this.server.on("connect", (req, socket) => { - socket.on("error", logger); + if (this.shouldHandleConnect) { + this.handleCONNECT(); + } else { + // reject connect if received and the proxy was initialized not to accept connect requests + this.server.on("connect", (_, socket) => { + socket.write("HTTP/1.1 400 Bad request\r\n\r\n"); + }); + } + + this.handleAPIInterception(); + } + + /** + * forward any http(s) connections intiated via CONNECT as is + */ + private handleCONNECT() { + this.server.on("connect", async (req, socket) => { + socket.on("error", this.logger); + const host = req.url?.split(":")[0]; const port = req.url?.split(":")[1]; - logger(`received connect request for ${host}:${port}`); + this.logger(`received connect request for ${host}:${port}`); if (!host || !port) { socket.write("HTTP/1.1 400 Bad request\r\n\r\n"); return; } - const target = net.createConnection({ host, port: parseInt(port) }); - target.on("error", logger); + + let targetHost = host, targetPort = port; + + // if connect request was issued for http target, then we handle it by accepting the connect, + // spinning up a secondary proxy and piping the request to the secondary proxy + if (port === "80") { + if (!this.secondaryProxy) { + // NOTE: make sure to disable CONNECT handling in the secondary proxy to avoid spinning up infinite proxies + // this can happen if the client is malicious and keeps on sending CONNECT requests + const proxy = new ForwardProxy(this.apis, false, false); + const proxyAddress = await proxy.start(); + this.secondaryProxy = { proxy, proxyAddress }; + } + const [_, secondaryProxyPort] = this.secondaryProxy.proxyAddress.split(":"); + targetHost = LOCALHOST; + targetPort = secondaryProxyPort; + } + + const target = net.createConnection({ host: targetHost, port: parseInt(targetPort) }); + target.on("error", this.logger); target.on("connect", () => { - logger(`connected to ${host}:${port}`); + this.logger(`connected to ${targetHost}:${targetPort}`); socket.write("HTTP/1.1 200 OK\r\n\r\n"); socket.pipe(target); target.pipe(socket); }); }); + } + private handleAPIInterception() { // mock all apis this.apis.forEach(api => api.reply()); // forward the intercepted api call - this.app.all("/*", function (req, res) { + this.app.all("/*", (req, res) => { + let path = req.path; + + // for http connections initiated via CONNECT, req.url resolves to be the path and not the entire url + try { + path += new URL(req.url).search; + } catch (err) { + path += new URL(`http://${req.hostname}${req.url}`).search; + } + const opts = { host: req.hostname, - path: req.path + new URL(req.url).search, + path, method: req.method, headers: req.headers, agent: false, }; - logger(JSON.stringify(opts)); + this.logger(JSON.stringify(opts)); const request = http.request(opts); - request.on("response", function (response) { + request.on("response", response => { // set status code if (response.statusCode) {res.status(response.statusCode);} diff --git a/src/proxy/proxy.types.ts b/src/proxy/proxy.types.ts index bd71462..c556e4f 100644 --- a/src/proxy/proxy.types.ts +++ b/src/proxy/proxy.types.ts @@ -2,6 +2,7 @@ import { Moctokit } from "@kie/mock-github"; import { Mockapi } from "@aj/mockapi/mockapi"; export type ResponseMocker = ReturnType | ReturnType>; +export const LOCALHOST = "127.0.0.1"; type Extract = { [K in keyof T]: { diff --git a/test/unit/proxy/proxy.test.ts b/test/unit/proxy/proxy.test.ts index c70aca4..5de84bc 100644 --- a/test/unit/proxy/proxy.test.ts +++ b/test/unit/proxy/proxy.test.ts @@ -1,15 +1,21 @@ import { ForwardProxy } from "@aj/proxy/proxy"; import { Mockapi } from "@aj/mockapi/mockapi"; -import axios from "axios"; -import { Octokit } from "@octokit/rest"; import { Moctokit } from "@kie/mock-github"; -import { spawn } from "child_process"; +import { SpawnOptionsWithoutStdio, spawn } from "child_process"; +import path from "path"; +import { rmSync, writeFileSync } from "fs"; -afterEach(() => { +const executeRequestFile = path.join(__dirname, "executeRequest.js"); + +afterEach(async () => { delete process.env["http_proxy"]; delete process.env["https_proxy"]; }); +afterAll(() => { + rmSync(executeRequestFile, { force: true }); +}); + describe("start", () => { test("success", async () => { const proxy = new ForwardProxy([]); @@ -42,7 +48,13 @@ describe("stop", () => { }); describe("http", () => { - test("mock", async () => { + let proxy: ForwardProxy; + + afterEach(async () => { + await proxy.stop(); + }); + + test("mock without CONNECT request", async () => { const mockapi = new Mockapi({ google: { baseUrl: "http://google.com", @@ -61,9 +73,9 @@ describe("http", () => { }, }, }); - const moctokit = new Moctokit(); + const moctokit = new Moctokit("http://api.github.com"); - const proxy = new ForwardProxy([ + proxy = new ForwardProxy([ mockapi.mock.google.root .get() .setResponse({ status: 200, data: { msg: "mocked_response" } }), @@ -72,25 +84,40 @@ describe("http", () => { .setResponse({ status: 200, data: { full_name: "mocked_name" } }), ]); const ip = await proxy.start(); - process.env["http_proxy"] = `http://${ip}`; - process.env["https_proxy"] = `http://${ip}`; - const axiosResponse = await axios.get("http://google.com/"); - expect(axiosResponse.data).toStrictEqual({ msg: "mocked_response" }); + const response = await executeFile( + ` + const axios = require("axios"); + const {getOctokit} = require("@actions/github") + async function run() { + const axiosResponse = await axios.get("http://google.com/"); + const octokit = getOctokit("token"); + const octokitResponse = await octokit.rest.repos.get({ + repo: "kiegroup", + owner: "kiegroup", + }); + console.log( + JSON.stringify({ axios: axiosResponse.data, octokit: octokitResponse.data }) + ); + } + run(); + `, + ip, + { + GITHUB_API_URL: "http://api.github.com", + } + ); - const octokit = new Octokit(); - const octokitResponse = await octokit.rest.repos.get({ - repo: "kiegroup", - owner: "kiegroup", + expect(JSON.parse(response.trim())).toStrictEqual({ + axios: { msg: "mocked_response" }, + octokit: { full_name: "mocked_name" }, }); - expect(octokitResponse.data).toStrictEqual({ full_name: "mocked_name" }); - await proxy.stop(); }); test("do not mock", async () => { const mockapi = new Mockapi({ - google: { - baseUrl: "http://google.com", + gmail: { + baseUrl: "http://gmail.com", endpoints: { root: { get: { @@ -107,27 +134,60 @@ describe("http", () => { }, }); - const proxy = new ForwardProxy([ - mockapi.mock.google.root + proxy = new ForwardProxy([ + mockapi.mock.gmail.root .get() .setResponse({ status: 400, data: { msg: "mocked_response" } }), ]); const ip = await proxy.start(); - process.env["http_proxy"] = `http://${ip}`; - process.env["https_proxy"] = `http://${ip}`; - const { status } = await axios.get("http://redhat.com/"); + const response = await executeFile( + ` + const axios = require("axios"); + axios.get("http://redhat.com/").then(d => console.log(d.status)) + `, + ip + ); - expect( - status - ).toBe(200); - await proxy.stop(); + expect(response.trim()).toBe("200"); + }); + + test("mock with CONNECT request", async () => { + const moctokit = new Moctokit("http://api.github.com"); + + proxy = new ForwardProxy([ + moctokit.rest.repos + .get() + .setResponse({ status: 200, data: { full_name: "mocked_name" } }), + ]); + const ip = await proxy.start(); + + const response = await executeFile( + ` + const {getOctokit} = require("@actions/github") + const octokit = getOctokit("token"); + octokit.rest.repos.get({ + repo: "kiegroup", + owner: "kiegroup", + }).then(data => console.log(JSON.stringify({status: data.status, data: data.data}))); + `, + ip, + { GITHUB_API_URL: "http://api.github.com" } + ); + + expect(JSON.parse(response.trim())).toStrictEqual({ + status: 200, + data: { full_name: "mocked_name" }, + }); }); }); describe("https", () => { - test("some clients send a CONNECT request, in which case don't mock it even if it matches", async () => { - const mockapi = new Mockapi({ + let mockapi: Mockapi; + let proxy: ForwardProxy; + + beforeEach(() => { + mockapi = new Mockapi({ google: { baseUrl: "http://google.com", endpoints: { @@ -145,46 +205,95 @@ describe("https", () => { }, }, }); + }); - const proxy = new ForwardProxy([ + afterEach(async () => { + await proxy.stop(); + }); + + test("don't mock when a CONNECT request is sent", async () => { + proxy = new ForwardProxy([ mockapi.mock.google.root .get() .setResponse({ status: 200, data: { msg: "mocked_response" } }), ]); const ip = await proxy.start(); - process.env["http_proxy"] = `http://${ip}`; - process.env["https_proxy"] = `http://${ip}`; - const response = await executeCurl(["-s", "https://google.com"]); + const response = await executeCurl(["-s", "https://google.com"], ip); expect(response).toMatch(/.+/); - await proxy.stop(); }); - test("some clients dont send a CONNECT request in which case mock it", async () => { - const moctokit = new Moctokit(); - - const proxy = new ForwardProxy([ - moctokit.rest.repos + test("mock when a CONNECT request is not sent", async () => { + proxy = new ForwardProxy([ + mockapi.mock.google.root .get() - .setResponse({ status: 200, data: { full_name: "mocked_name" } }), + .setResponse({ status: 200, data: { msg: "mocked_response" } }), ]); const ip = await proxy.start(); - process.env["http_proxy"] = `http://${ip}`; - process.env["https_proxy"] = `http://${ip}`; - const octokit = new Octokit(); - const response = await octokit.rest.repos.get({ - repo: "kiegroup", - owner: "kiegroup", - }); - expect(response.data).toStrictEqual({ full_name: "mocked_name" }); - await proxy.stop(); + const response = await executeFile( + ` + const axios = require("axios"); + axios.get("https://google.com").then(d => console.log(JSON.stringify({status: d.status, data: d.data}))) + `, + ip + ); + + expect(JSON.parse(response.trim())).toStrictEqual({ + status: 200, + data: { msg: "mocked_response" }, + }); }); }); -async function executeCurl(args: string[]) { +async function executeCurl( + args: string[], + ip: string, + additionalEnv: SpawnOptionsWithoutStdio["env"] = {} +) { return new Promise((resolve, reject) => { - const childProcess = spawn("curl", args); + const childProcess = spawn("curl", args, { + env: { + ...process.env, + ...additionalEnv, + http_proxy: `http://${ip}`, + https_proxy: `http://${ip}`, + }, + }); + let data = ""; + let error = ""; + childProcess.stdout.on("data", chunk => { + data += chunk.toString(); + }); + childProcess.stderr.on("data", chunk => { + error += chunk.toString(); + }); + + childProcess.on("close", code => { + if (code === null) { + reject(error); + } else { + resolve(data); + } + }); + }); +} + +async function executeFile( + request: string, + ip: string, + additionalEnv: SpawnOptionsWithoutStdio["env"] = {} +): Promise { + writeFileSync(executeRequestFile, request); + return new Promise((resolve, reject) => { + const childProcess = spawn("node", [executeRequestFile], { + env: { + ...process.env, + ...additionalEnv, + http_proxy: `http://${ip}`, + https_proxy: `http://${ip}`, + }, + }); let data = ""; let error = ""; childProcess.stdout.on("data", chunk => {