├── .editorconfig ├── .github ├── dependabot.yaml ├── scripts │ └── before-beta-release.js └── workflows │ ├── check.yaml │ └── release.yaml ├── .gitignore ├── .mocharc.json ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── eslint.config.mjs ├── examples └── apify_proxy_tunnel.js ├── jest.config.ts ├── package.json ├── src ├── anonymize_proxy.ts ├── chain.ts ├── chain_socks.ts ├── custom_connect.ts ├── custom_response.ts ├── direct.ts ├── forward.ts ├── forward_socks.ts ├── index.ts ├── request_error.ts ├── server.ts ├── socket.ts ├── statuses.ts ├── tcp_tunnel_tools.ts └── utils │ ├── count_target_bytes.ts │ ├── decode_uri_component_safe.ts │ ├── get_basic.ts │ ├── is_hop_by_hop_header.ts │ ├── nodeify.ts │ ├── normalize_url_port.ts │ ├── parse_authorization_header.ts │ ├── redact_url.ts │ └── valid_headers_only.ts ├── test ├── .eslintrc.json ├── Dockerfile ├── README.md ├── anonymize_proxy.js ├── anonymize_proxy_no_password.js ├── ee-memory-leak.js ├── phantom_get.js ├── server.js ├── socks.js ├── ssl.crt ├── ssl.key ├── tcp_tunnel.js ├── tools.js └── utils │ ├── run_locally.js │ ├── target_server.js │ ├── testing_tcp_service.js │ └── throws_async.js ├── tsconfig.eslint.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | # editorconfig-tools is unable to ignore longs strings or urls 11 | max_line_length = null 12 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: eslint-plugin-promise 10 | versions: 11 | - 5.1.0 12 | - dependency-name: sinon 13 | versions: 14 | - 10.0.0 15 | - 10.0.1 16 | -------------------------------------------------------------------------------- /.github/scripts/before-beta-release.js: -------------------------------------------------------------------------------- 1 | const { execSync } = require('node:child_process'); 2 | const fs = require('node:fs'); 3 | const path = require('node:path'); 4 | 5 | const PKG_JSON_PATH = path.join(__dirname, '..', '..', 'package.json'); 6 | 7 | // eslint-disable-next-line import/no-dynamic-require 8 | const pkgJson = require(PKG_JSON_PATH); 9 | 10 | const PACKAGE_NAME = pkgJson.name; 11 | const VERSION = pkgJson.version; 12 | 13 | const nextVersion = getNextVersion(VERSION); 14 | console.log(`before-deploy: Setting version to ${nextVersion}`); 15 | pkgJson.version = nextVersion; 16 | 17 | fs.writeFileSync(PKG_JSON_PATH, `${JSON.stringify(pkgJson, null, 2)}\n`); 18 | 19 | function getNextVersion(version) { 20 | const versionString = execSync(`npm show ${PACKAGE_NAME} versions --json`, { encoding: 'utf8' }); 21 | const versions = JSON.parse(versionString); 22 | 23 | if (versions.some((v) => v === VERSION)) { 24 | console.error(`before-deploy: A release with version ${VERSION} already exists. Please increment version accordingly.`); 25 | process.exit(1); 26 | } 27 | 28 | const prereleaseNumbers = versions 29 | .filter((v) => (v.startsWith(VERSION) && v.includes('-'))) 30 | .map((v) => Number(v.match(/\.(\d+)$/)[1])); 31 | const lastPrereleaseNumber = Math.max(-1, ...prereleaseNumbers); 32 | return `${version}-beta.${lastPrereleaseNumber + 1}`; 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | # This workflow runs for every pull request to lint and test the proposed changes. 2 | 3 | name: Check 4 | 5 | on: 6 | pull_request: 7 | 8 | jobs: 9 | # NPM install is done in a separate job and cached to speed up the following jobs. 10 | build_and_test: 11 | name: Build & Test 12 | if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} 13 | runs-on: ${{ matrix.os }} 14 | 15 | strategy: 16 | matrix: 17 | os: [ubuntu-22.04] # add windows-latest later 18 | node-version: [14, 16, 18] 19 | 20 | steps: 21 | - 22 | uses: actions/checkout@v2 23 | - 24 | name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - 29 | name: Cache Node Modules 30 | if: ${{ matrix.node-version == 18 }} 31 | uses: actions/cache@v4 32 | with: 33 | path: | 34 | node_modules 35 | build 36 | key: cache-${{ github.run_id }}-v18 37 | - 38 | name: Install Dependencies 39 | run: npm install 40 | - 41 | name: Add localhost-test to Linux hosts file 42 | if: ${{ matrix.os == 'ubuntu-22.04' }} 43 | run: sudo echo "127.0.0.1 localhost-test" | sudo tee -a /etc/hosts 44 | # - 45 | # name: Add localhost-test to Windows hosts file 46 | # if: ${{ matrix.os == 'windows-latest' }} 47 | # run: echo "`n127.0.0.1 localhost-test">>C:\Windows\System32\drivers\etc\hosts 48 | - 49 | name: Run Tests 50 | run: npm test 51 | 52 | lint: 53 | name: Lint 54 | needs: [build_and_test] 55 | runs-on: ubuntu-22.04 56 | 57 | steps: 58 | - 59 | uses: actions/checkout@v2 60 | - 61 | name: Use Node.js 18 62 | uses: actions/setup-node@v1 63 | with: 64 | node-version: 18 65 | - 66 | name: Load Cache 67 | uses: actions/cache@v4 68 | with: 69 | path: | 70 | node_modules 71 | build 72 | key: cache-${{ github.run_id }}-v18 73 | - 74 | run: npm run lint 75 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Check & Release 2 | 3 | on: 4 | # Push to master will deploy a beta version 5 | push: 6 | branches: 7 | - master 8 | # A release via GitHub releases will deploy a latest version 9 | release: 10 | types: [ published ] 11 | 12 | jobs: 13 | # NPM install is done in a separate job and cached to speed up the following jobs. 14 | build_and_test: 15 | name: Build & Test 16 | if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} 17 | runs-on: ${{ matrix.os }} 18 | 19 | strategy: 20 | matrix: 21 | os: [ubuntu-22.04] # add windows-latest later 22 | node-version: [14, 16, 18] 23 | 24 | steps: 25 | - 26 | uses: actions/checkout@v2 27 | - 28 | name: Use Node.js ${{ matrix.node-version }} 29 | uses: actions/setup-node@v1 30 | with: 31 | node-version: ${{ matrix.node-version }} 32 | - 33 | name: Cache Node Modules 34 | if: ${{ matrix.node-version == 18 }} 35 | uses: actions/cache@v4 36 | with: 37 | path: | 38 | node_modules 39 | build 40 | key: cache-${{ github.run_id }}-v18 41 | - 42 | name: Install Dependencies 43 | run: npm install 44 | - name: Add localhost-test to Linux hosts file 45 | if: ${{ matrix.os == 'ubuntu-22.04' }} 46 | run: sudo echo "127.0.0.1 localhost-test" | sudo tee -a /etc/hosts 47 | # - 48 | # name: Add localhost-test to Windows hosts file 49 | # if: ${{ matrix.os == 'windows-latest' }} 50 | # run: echo "`n127.0.0.1 localhost-test">>C:\Windows\System32\drivers\etc\hosts 51 | - 52 | name: Run Tests 53 | run: npm test 54 | 55 | lint: 56 | name: Lint 57 | needs: [build_and_test] 58 | runs-on: ubuntu-22.04 59 | 60 | steps: 61 | - 62 | uses: actions/checkout@v2 63 | - 64 | name: Use Node.js 18 65 | uses: actions/setup-node@v1 66 | with: 67 | node-version: 18 68 | - 69 | name: Load Cache 70 | uses: actions/cache@v4 71 | with: 72 | path: | 73 | node_modules 74 | build 75 | key: cache-${{ github.run_id }}-v18 76 | - 77 | run: npm run lint 78 | 79 | 80 | # The deploy job is long but there are only 2 important parts. NPM publish 81 | # and triggering of docker image builds in the apify-actor-docker repo. 82 | deploy: 83 | name: Publish to NPM 84 | needs: [lint] 85 | runs-on: ubuntu-22.04 86 | steps: 87 | - 88 | uses: actions/checkout@v2 89 | - 90 | uses: actions/setup-node@v1 91 | with: 92 | node-version: 18 93 | registry-url: https://registry.npmjs.org/ 94 | - 95 | name: Load Cache 96 | uses: actions/cache@v4 97 | with: 98 | path: | 99 | node_modules 100 | build 101 | key: cache-${{ github.run_id }}-v18 102 | - 103 | # Determine if this is a beta or latest release 104 | name: Set Release Tag 105 | run: echo "RELEASE_TAG=$(if [ ${{ github.event_name }} = release ]; then echo latest; else echo beta; fi)" >> $GITHUB_ENV 106 | - 107 | # Check version consistency and increment pre-release version number for beta only. 108 | name: Bump pre-release version 109 | if: env.RELEASE_TAG == 'beta' 110 | run: node ./.github/scripts/before-beta-release.js 111 | - 112 | name: Publish to NPM 113 | run: NODE_AUTH_TOKEN=${{secrets.NPM_TOKEN}} npm publish --tag ${{ env.RELEASE_TAG }} --access public 114 | - 115 | # Latest version is tagged by the release process so we only tag beta here. 116 | name: Tag Version 117 | if: env.RELEASE_TAG == 'beta' 118 | run: | 119 | git_tag=v`node -p "require('./package.json').version"` 120 | git tag $git_tag 121 | git push origin $git_tag 122 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | *.log 4 | *.pid 5 | *.seed 6 | .DS_Store 7 | lib 8 | coverage 9 | logs 10 | pids 11 | .idea 12 | yarn.lock 13 | docs 14 | package-lock.json 15 | .nyc_output 16 | dist 17 | .vscode 18 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": "ts-node/register" 3 | } 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Keep empty .npmignore file here so that /build is not ignored by "npm publish" 2 | # as it is in .gitignore file. Yes, this happened in older versions of NPM. 3 | # See https://docs.npmjs.com/misc/developers for info. 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2.0.1 / 2022-05-02 2 | ================== 3 | - Simplify code, fix tests, move to TypeScript [#162](https://github.com/apify/proxy-chain/pull/162) 4 | - Bugfix: Memory leak in createTunnel [#160](https://github.com/apify/proxy-chain/issues/160) 5 | - Bugfix: Proxy fails to handle non-standard HTTP response in HTTP forwarding mode, on certain websites [#107](https://github.com/apify/proxy-chain/issues/107) 6 | - Pass proxyChainId to tunnelConnectResponded [#173](https://github.com/apify/proxy-chain/pull/173) 7 | - feat: accept custom port for proxy anonymization [#214](https://github.com/apify/proxy-chain/pull/214) 8 | - fix: socket close race condition 9 | - feat: closeConnection by id [#176](https://github.com/apify/proxy-chain/pull/176) 10 | - feat: custom dns lookup [#175](https://github.com/apify/proxy-chain/pull/175) 11 | 12 | 1.0.3 / 2021-08-17 13 | ================== 14 | - Fixed `EventEmitter` memory leak (see issue [#81](https://github.com/apify/proxy-chain/issues/81)) 15 | - Added automated tests for Node 16 16 | - Updated dev dependencies 17 | 18 | 1.0.2 / 2021-04-14 19 | ================== 20 | - Bugfix: `closeTunnel()` function didn't work because of `runningServers[port].connections.forEach is not a function` error (see issue [#127](https://github.com/apify/proxy-chain/issues/127)) 21 | 22 | 1.0.1 / 2021-04-09 23 | ================== 24 | - Bugfix: `parseUrl()` result now always includes port for `http(s)`, `ftp` and `ws(s)` (even if explicitly specified port is the default one) 25 | This fixes [#123](https://github.com/apify/proxy-chain/issues/123). 26 | 27 | 1.0.0 / 2021-03-17 28 | =================== 29 | - **BREAKING:** The `parseUrl()` function slightly changed its behavior (see README for details): 30 | - it no longer returns an object on invalid URLs and throws an exception instead 31 | - it URI-decodes username and password if possible 32 | (if not, the function keeps the username and password as is) 33 | - it adds back `auth` property for better backwards compatibility 34 | - The above change should make it possible to pass upstream proxy URLs containing 35 | special characters, such as `http://user:pass:wrd@proxy.example.com` 36 | or `http://us%35er:passwrd@proxy.example.com`. The parsing is done on a best-effort basis. 37 | The safest way is to always URI-encode username and password before constructing 38 | the URL, according to RFC 3986. 39 | This change should finally fix issues: 40 | [#89](https://github.com/apify/proxy-chain/issues/89), 41 | [#67](https://github.com/apify/proxy-chain/issues/67), 42 | and [#108](https://github.com/apify/proxy-chain/issues/108) 43 | - **BREAKING:** Improved error handling in `createTunnel()` and `prepareRequestFunction()` functions 44 | and provided better error messages. Both functions now fail if the upstream proxy 45 | URL contains colon (`:`) character in the username, in order to comply with RFC 7617. 46 | The functions now fail fast with a reasonable error, rather later and with cryptic errors. 47 | - **BREAKING:** The `createTunnel()` function now lets the system assign potentially 48 | random listening TCP port, instead of the previous selection from range from 20000 to 60000. 49 | - **BREAKING:** The undocumented `findFreePort()` function was moved from tools.js to test/tools.js 50 | - Added the [ability to access proxy CONNECT headers](https://github.com/apify/proxy-chain#accessing-the-connect-response-headers-for-proxy-tunneling) for proxy tunneling. 51 | - Removed dependency on Node.js internal modules, hopefully allowing usage of this library in Electron. 52 | - Got rid of the "portastic" NPM package and thus reduced bundle size by ~50% 53 | - Various code improvements and better tests. 54 | - Updated packages. 55 | 56 | 0.4.9 / 2021-01-26 57 | =================== 58 | - Bugfix: Added back the `scheme` field to result from `parseUrl()` 59 | 60 | 0.4.8 / 2021-01-26 61 | =================== 62 | - Bugfix: `parseUrl()` function now handles IPv6 and other previously unsupported URLs. 63 | Fixes issues [#89](https://github.com/apify/proxy-chain/issues/89) 64 | and [#67](https://github.com/apify/proxy-chain/issues/67). 65 | 66 | 0.4.7 / 2021-01-19 67 | =================== 68 | - Bugfix: `closeTunnel()` function was returning invalid value. 69 | see PR [#98](https://github.com/apify/proxy-chain/pull/101). 70 | 71 | 0.4.6 / 2020-11-09 72 | =================== 73 | - `Proxy.Server` now supports `port: 0` option to assign the port randomly, 74 | see PR [#98](https://github.com/apify/proxy-chain/pull/98). 75 | - `anonymizeProxy()` now uses the above port assignment rather than polling for random port => better performance 76 | - Updated NPM packages 77 | 78 | 0.4.5 / 2020-05-15 79 | =================== 80 | - Added checks for closed handlers, in order to prevent the `Cannot read property 'pipe' of null` errors 81 | (see issue [#64](https://github.com/apify/proxy-chain/issues/64)) 82 | 83 | 0.4.4 / 2020-03-12 84 | =================== 85 | - Attempt to fix an unhandled exception in `HandlerTunnelChain.onTrgRequestConnect` 86 | (see issue [#64](https://github.com/apify/proxy-chain/issues/64)) 87 | - Code cleanup 88 | 89 | 0.4.3 / 2020-03-08 90 | =================== 91 | - Fixed unhandled `TypeError: Cannot read property '_httpMessage' of null` exception 92 | in `HandlerTunnelChain.onTrgRequestConnect` (see issue [#63](https://github.com/apify/proxy-chain/issues/63)) 93 | 94 | 0.4.2 / 2020-02-28 95 | =================== 96 | - Bugfix: Prevented attempted double-sending of certain HTTP responses to client, 97 | which might have caused some esoteric errors 98 | - Error responses now by default have `Content-Type: text/plain; charset=utf-8` instead 99 | of `text/html; charset=utf-8` or missing one. 100 | 101 | 0.4.1 / 2020-02-22 102 | =================== 103 | - Increased socket end/destroy timeouts from 100ms to 1000ms, to ensure the client 104 | receives the data. 105 | 106 | 0.4.0 / 2020-02-22 107 | =================== 108 | - **BREAKING CHANGE**: Dropped support for Node.js 9 and lower. 109 | - BUGFIX: Consume source socket errors to avoid unhandled exceptions. 110 | Fixes [Issue #53](https://github.com/apify/proxy-chain/issues/53). 111 | - BUGFIX: Renamed misspelled `Trailers` HTTP header to `Trailer`. 112 | - Replaced `bluebird` dependency with native Promises. 113 | - Upgraded NPM dev dependencies. 114 | - Fixed broken tests caused by newly introduced strict HTTP parsing in Node.js. 115 | - Fixed broken test on Node.js 10 by adding `NODE_OPTIONS=--insecure-http-parser` env var to `npm test`. 116 | 117 | 0.3.3 / 2019-12-27 118 | =================== 119 | - More informative messages for "Invalid upstreamProxyUrl" errors 120 | 121 | 0.3.2 / 2019-09-17 122 | =================== 123 | - Bugfix: Prevent the `"TypeError: hostHeader.startsWith is not a function` error 124 | in `HandlerForward` by not forwarding duplicate `Host` headers 125 | 126 | 0.3.1 / 2019-09-07 127 | =================== 128 | - **BREAKING CHANGE**: `closeAnonymizedProxy` throws on invalid proxy URL 129 | - Bugfix: Attempt to prevent the unhandled "write after end" error 130 | - Bugfix: Proxy no longer attempts to forward invalid 131 | HTTP status codes and fails with 500 Internal Server Error 132 | - Fixed closing of sockets on Node 10+ 133 | - Fixed and improved unit tests to also work on Node 10+, update dev dependencies 134 | - Changed HTTP 200 message from `Connection established` to `Connection Established` 135 | to be according to standards 136 | - Proxy source/target sockets are set to no delay (i.e. disabled Nagle's algorithm), to avoid any caching delays 137 | - Improved logging 138 | 139 | 0.2.7 / 2018-02-19 140 | =================== 141 | - Updated README 142 | 143 | 0.2.6 / 2018-12-27 144 | =================== 145 | - Bugfix: Added `Host` header to `HTTP CONNECT` requests to upstream proxies 146 | 147 | 0.2.5 / 2018-09-10 148 | =================== 149 | - Bugfix: Invalid request headers broke proxy chain connection. Now they will be skipped instead. 150 | 151 | 0.2.4 / 2018-07-27 152 | =================== 153 | - Bugfix: large custom responses were not delivered completely because the socket was closed too early 154 | 155 | 0.2.3 / 2018-06-21 156 | =================== 157 | - Bugfix: 'requestFailed' was emitting `{ request, err }` instead of `{ request, error }` 158 | 159 | 0.2.2 / 2018-06-19 160 | =================== 161 | - BREAKING: The 'requestFailed' event now emits object `{ request, error }` instead of just `error` 162 | 163 | 0.1.35 / 2018-06-12 164 | =================== 165 | - Bugfix: When target URL cannot be parsed instead of crashing, throw RequestError 166 | 167 | 0.1.34 / 2018-06-08 168 | =================== 169 | - Minor improvement: HandlerBase.fail() now supports RequestError 170 | 171 | 0.1.33 / 2018-06-08 172 | =================== 173 | - Renamed `customResponseFunc` to `customResponseFunction` and changed parameters for more clarity 174 | 175 | 0.1.32 / 2018-06-08 176 | =================== 177 | - Added `customResponseFunc` option to `prepareRequestFunction` to support custom response to HTTP requests 178 | 179 | 0.1.31 / 2018-05-21 180 | =================== 181 | - Updated project homepage in package.json 182 | 183 | 0.1.29 / 2018-04-15 184 | =================== 185 | - Fix: anonymizeProxy() now supports upstream proxies with empty password 186 | 187 | 0.1.28 / 2018-03-27 188 | =================== 189 | - Added `createTunnel()` function to create tunnels through HTTP proxies for arbitrary TCP network connections 190 | (eq. connection to mongodb/sql database through HTTP proxy) 191 | 192 | 0.1.27 / 2018-03-05 193 | =================== 194 | - Better error messages for common network errors 195 | - Pass headers from target socket in HTTPS tunnel chains 196 | 197 | 0.1.26 / 2018-02-14 198 | =================== 199 | - If connection is denied because of authentication error, optionally "prepareRequestFunction" can provide error message. 200 | 201 | 0.1.25 / 2018-02-12 202 | =================== 203 | - When connection is only through socket, close srcSocket when trgSocket ends 204 | 205 | 0.1.24 / 2018-02-09 206 | =================== 207 | - Fixed incorrect closing of ServerResponse object which caused phantomjs to mark resource requests as errors. 208 | 209 | 0.1.23 / 2018-02-07 210 | =================== 211 | - Fixed missing variable in "Incorrect protocol" error message. 212 | 213 | 0.1.22 / 2018-02-05 214 | =================== 215 | - Renamed project's GitHub organization 216 | 217 | 0.1.21 / 2018-01-26 218 | =================== 219 | - Added Server.getConnectionIds() function 220 | 221 | 0.1.20 / 2018-01-26 222 | =================== 223 | - Fixed "TypeError: The header content contains invalid characters" bug 224 | 225 | 0.1.19 / 2018-01-25 226 | =================== 227 | - fixed uncaught error events, code improved 228 | 229 | 0.1.18 / 2018-01-25 230 | =================== 231 | - fixed a memory leak, improved logging and consolidated code 232 | 233 | 0.1.17 / 2018-01-23 234 | =================== 235 | - added `connectionClosed` event to notify users about closed proxy connections 236 | 237 | 0.1.16 / 2018-01-09 238 | =================== 239 | - added measuring of proxy stats - see `getConnectionStats()` function 240 | 241 | 0.1.14 / 2017-12-19 242 | =================== 243 | - added support for multiple headers with the same name (thx shershennm) 244 | 245 | 0.0.1 / 2017-11-06 246 | =================== 247 | - Project created 248 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2018 Apify Technologies s.r.o. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Programmable HTTP proxy server for Node.js 2 | 3 | [![npm version](https://badge.fury.io/js/proxy-chain.svg)](http://badge.fury.io/js/proxy-chain) 4 | 5 | A programmable proxy server (think Squid) with support for SSL/TLS, authentication, upstream proxy chaining, SOCKS4/5 protocol, 6 | custom HTTP responses, and traffic statistics. 7 | The authentication and proxy chaining configuration is defined in code and can be fully dynamic, giving you a high level of customization for your use case. 8 | 9 | For example, the proxy-chain package is useful if you need to use headless Chrome web browser and proxies with authentication, 10 | because Chrome doesn't support proxy URLs with password, such as `http://username:password@proxy.example.com:8080`. 11 | With this package, you can set up a local proxy server without any password 12 | that will forward requests to the upstream proxy with password. 13 | For details, read [How to make headless Chrome and Puppeteer use a proxy server with authentication](https://blog.apify.com/how-to-make-headless-chrome-and-puppeteer-use-a-proxy-server-with-authentication-249a21a79212/). 14 | 15 | The proxy-chain package is developed by [Apify](https://apify.com/), the full-stack web scraping and data extraction platform, to support their [Apify Proxy](https://apify.com/proxy) product, 16 | which provides an easy access to a large pool of datacenter and residential IP addresses all around the world. The proxy-chain package is also used by [Crawlee](https://crawlee.dev/), 17 | the world's most popular web craling library for Node.js. 18 | 19 | The proxy-chain package currently supports HTTP/SOCKS forwarding and HTTP CONNECT tunneling to forward arbitrary protocols such as HTTPS or FTP ([learn more](https://blog.apify.com/tunneling-arbitrary-protocols-over-http-proxy-with-static-ip-address-b3a2222191ff)). The HTTP CONNECT tunneling also supports the SOCKS protocol. Also, proxy-chain only supports the Basic [Proxy-Authorization](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Proxy-Authorization). 20 | 21 | ## Run a simple HTTP/HTTPS proxy server 22 | 23 | ```javascript 24 | const ProxyChain = require('proxy-chain'); 25 | 26 | const server = new ProxyChain.Server({ port: 8000 }); 27 | 28 | server.listen(() => { 29 | console.log(`Proxy server is listening on port ${server.port}`); 30 | }); 31 | ``` 32 | 33 | ## Run a HTTP/HTTPS proxy server with credentials and upstream proxy 34 | 35 | ```javascript 36 | const ProxyChain = require('proxy-chain'); 37 | 38 | const server = new ProxyChain.Server({ 39 | // Port where the server will listen. By default 8000. 40 | port: 8000, 41 | 42 | // Optional host where the proxy server will listen. 43 | // If not specified, the sever listens on an unspecified IP address (0.0.0.0 in IPv4, :: in IPv6) 44 | // You can use this option to limit the access to the proxy server. 45 | host: 'localhost', 46 | 47 | // Enables verbose logging 48 | verbose: true, 49 | 50 | // Custom user-defined function to authenticate incoming proxy requests, 51 | // and optionally provide the URL to chained upstream proxy. 52 | // The function must return an object (or promise resolving to the object) with the following signature: 53 | // { requestAuthentication: boolean, upstreamProxyUrl: string, failMsg?: string, customTag?: unknown } 54 | // If the function is not defined or is null, the server runs in simple mode. 55 | // Note that the function takes a single argument with the following properties: 56 | // * request - An instance of http.IncomingMessage class with information about the client request 57 | // (which is either HTTP CONNECT for SSL protocol, or other HTTP request) 58 | // * username - Username parsed from the Proxy-Authorization header. Might be empty string. 59 | // * password - Password parsed from the Proxy-Authorization header. Might be empty string. 60 | // * hostname - Hostname of the target server 61 | // * port - Port of the target server 62 | // * isHttp - If true, this is a HTTP request, otherwise it's a HTTP CONNECT tunnel for SSL 63 | // or other protocols 64 | // * connectionId - Unique ID of the HTTP connection. It can be used to obtain traffic statistics. 65 | prepareRequestFunction: ({ request, username, password, hostname, port, isHttp, connectionId }) => { 66 | return { 67 | // If set to true, the client is sent HTTP 407 resposne with the Proxy-Authenticate header set, 68 | // requiring Basic authentication. Here you can verify user credentials. 69 | requestAuthentication: username !== 'bob' || password !== 'TopSecret', 70 | 71 | // Sets up an upstream HTTP/HTTPS/SOCKS proxy to which all the requests are forwarded. 72 | // If null, the proxy works in direct mode, i.e. the connection is forwarded directly 73 | // to the target server. This field is ignored if "requestAuthentication" is true. 74 | // The username and password must be URI-encoded. 75 | upstreamProxyUrl: `http://username:password@proxy.example.com:3128`, 76 | // Or use SOCKS4/5 proxy, e.g. 77 | // upstreamProxyUrl: `socks://username:password@proxy.example.com:1080`, 78 | 79 | // Applies to HTTPS upstream proxy. If set to true, requests made to the proxy will 80 | // ignore certificate errors. Useful when upstream proxy uses self-signed certificate. By default "false". 81 | ignoreUpstreamProxyCertificate: true 82 | 83 | // If "requestAuthentication" is true, you can use the following property 84 | // to define a custom error message to return to the client instead of the default "Proxy credentials required" 85 | failMsg: 'Bad username or password, please try again.', 86 | 87 | // Optional custom tag that will be passed back via 88 | // `tunnelConnectResponded` or `tunnelConnectFailed` events 89 | // Can be used to pass information between proxy-chain 90 | // and any external code or application using it 91 | customTag: { userId: '123' }, 92 | }; 93 | }, 94 | }); 95 | 96 | server.listen(() => { 97 | console.log(`Proxy server is listening on port ${server.port}`); 98 | }); 99 | 100 | // Emitted when HTTP connection is closed 101 | server.on('connectionClosed', ({ connectionId, stats }) => { 102 | console.log(`Connection ${connectionId} closed`); 103 | console.dir(stats); 104 | }); 105 | 106 | // Emitted when HTTP request fails 107 | server.on('requestFailed', ({ request, error }) => { 108 | console.log(`Request ${request.url} failed`); 109 | console.error(error); 110 | }); 111 | ``` 112 | 113 | ## SOCKS support 114 | SOCKS protocol is supported for versions 4 and 5, specifically: `['socks', 'socks4', 'socks4a', 'socks5', 'socks5h']`, where `socks` will default to version 5. 115 | 116 | You can use an `upstreamProxyUrl` like `socks://username:password@proxy.example.com:1080`. 117 | 118 | ## Error status codes 119 | 120 | The `502 Bad Gateway` HTTP status code is not comprehensive enough. Therefore, the server may respond with `590-599` instead: 121 | 122 | ### `590 Non Successful` 123 | 124 | Upstream responded with non-200 status code. 125 | 126 | ### `591 RESERVED` 127 | 128 | *This status code is reserved for further use.* 129 | 130 | ### `592 Status Code Out Of Range` 131 | 132 | Upstream respondend with status code different than 100-999. 133 | 134 | ### `593 Not Found` 135 | 136 | DNS lookup failed - [`EAI_NODATA`](https://github.com/libuv/libuv/blob/cdbba74d7a756587a696fb3545051f9a525b85ac/include/uv.h#L82) or [`EAI_NONAME`](https://github.com/libuv/libuv/blob/cdbba74d7a756587a696fb3545051f9a525b85ac/include/uv.h#L83). 137 | 138 | ### `594 Connection Refused` 139 | 140 | Upstream refused connection. 141 | 142 | ### `595 Connection Reset` 143 | 144 | Connection reset due to loss of connection or timeout. 145 | 146 | ### `596 Broken Pipe` 147 | 148 | Trying to write on a closed socket. 149 | 150 | ### `597 Auth Failed` 151 | 152 | Incorrect upstream credentials. 153 | 154 | ### `598 RESERVED` 155 | 156 | *This status code is reserved for further use.* 157 | 158 | ### `599 Upstream Error` 159 | 160 | Generic upstream error. 161 | 162 | --- 163 | 164 | `590` and `592` indicate an issue on the upstream side. \ 165 | `593` indicates an incorrect `proxy-chain` configuration.\ 166 | `594`, `595` and `596` may occur due to connection loss.\ 167 | `597` indicates incorrect upstream credentials.\ 168 | `599` is a generic error, where the above is not applicable. 169 | 170 | ## Custom error responses 171 | 172 | To return a custom HTTP response to indicate an error to the client, 173 | you can throw the `RequestError` from inside of the `prepareRequestFunction` function. 174 | The class constructor has the following parameters: `RequestError(body, statusCode, headers)`. 175 | By default, the response will have `Content-Type: text/plain; charset=utf-8`. 176 | 177 | ```javascript 178 | const ProxyChain = require('proxy-chain'); 179 | 180 | const server = new ProxyChain.Server({ 181 | prepareRequestFunction: ({ request, username, password, hostname, port, isHttp, connectionId }) => { 182 | if (username !== 'bob') { 183 | throw new ProxyChain.RequestError('Only Bob can use this proxy!', 400); 184 | } 185 | }, 186 | }); 187 | ``` 188 | 189 | ## Measuring traffic statistics 190 | 191 | To get traffic statistics for a certain HTTP connection, you can use: 192 | ```javascript 193 | const stats = server.getConnectionStats(connectionId); 194 | console.dir(stats); 195 | ``` 196 | 197 | The resulting object looks like: 198 | ```javascript 199 | { 200 | // Number of bytes sent to client 201 | srcTxBytes: Number, 202 | // Number of bytes received from client 203 | srcRxBytes: Number, 204 | // Number of bytes sent to target server (proxy or website) 205 | trgTxBytes: Number, 206 | // Number of bytes received from target server (proxy or website) 207 | trgRxBytes: Number, 208 | } 209 | ``` 210 | 211 | If the underlying sockets were closed, the corresponding values will be `null`, 212 | rather than `0`. 213 | 214 | ## Custom responses 215 | 216 | Custom responses allow you to override the response to a HTTP requests to the proxy, without contacting any target host. 217 | For example, this is useful if you want to provide a HTTP proxy-style interface 218 | to an external API or respond with some custom page to certain requests. 219 | Note that this feature is only available for HTTP connections. That's because HTTPS 220 | connections cannot be intercepted without access to the target host's private key. 221 | 222 | To provide a custom response, the result of the `prepareRequestFunction` function must 223 | define the `customResponseFunction` property, which contains a function that generates the custom response. 224 | The function is passed no parameters and it must return an object (or a promise resolving to an object) 225 | with the following properties: 226 | 227 | ```javascript 228 | { 229 | // Optional HTTP status code of the response. By default it is 200. 230 | statusCode: 200, 231 | 232 | // Optional HTTP headers of the response 233 | headers: { 234 | 'X-My-Header': 'bla bla', 235 | } 236 | 237 | // Optional string with the body of the HTTP response 238 | body: 'My custom response', 239 | 240 | // Optional encoding of the body. If not provided, defaults to 'UTF-8' 241 | encoding: 'UTF-8', 242 | } 243 | ``` 244 | 245 | Here is a simple example: 246 | 247 | ```javascript 248 | const ProxyChain = require('proxy-chain'); 249 | 250 | const server = new ProxyChain.Server({ 251 | port: 8000, 252 | prepareRequestFunction: ({ request, username, password, hostname, port, isHttp }) => { 253 | return { 254 | customResponseFunction: () => { 255 | return { 256 | statusCode: 200, 257 | body: `My custom response to ${request.url}`, 258 | }; 259 | }, 260 | }; 261 | }, 262 | }); 263 | 264 | server.listen(() => { 265 | console.log(`Proxy server is listening on port ${server.port}`); 266 | }); 267 | ``` 268 | 269 | ## Routing CONNECT to another HTTP server 270 | 271 | While `customResponseFunction` enables custom handling methods such as `GET` and `POST`, many HTTP clients rely on `CONNECT` tunnels. 272 | It's possible to route those requests differently using the `customConnectServer` option. It accepts an instance of Node.js HTTP server. 273 | 274 | ```javascript 275 | const http = require('http'); 276 | const ProxyChain = require('proxy-chain'); 277 | 278 | const exampleServer = http.createServer((request, response) => { 279 | response.end('Hello from a custom server!'); 280 | }); 281 | 282 | const server = new ProxyChain.Server({ 283 | port: 8000, 284 | prepareRequestFunction: ({ request, username, password, hostname, port, isHttp }) => { 285 | if (request.url.toLowerCase() === 'example.com:80') { 286 | return { 287 | customConnectServer: exampleServer, 288 | }; 289 | } 290 | 291 | return {}; 292 | }, 293 | }); 294 | 295 | server.listen(() => { 296 | console.log(`Proxy server is listening on port ${server.port}`); 297 | }); 298 | ``` 299 | 300 | In the example above, all CONNECT tunnels to `example.com` are overridden. 301 | This is an unsecure server, so it accepts only `http:` requests. 302 | 303 | In order to intercept `https:` requests, `https.createServer` should be used instead, along with a self signed certificate. 304 | 305 | ```javascript 306 | const https = require('https'); 307 | const fs = require('fs'); 308 | const key = fs.readFileSync('./test/ssl.key'); 309 | const cert = fs.readFileSync('./test/ssl.crt'); 310 | 311 | const exampleServer = https.createServer({ 312 | key, 313 | cert, 314 | }, (request, response) => { 315 | response.end('Hello from a custom server!'); 316 | }); 317 | ``` 318 | 319 | ## Closing the server 320 | 321 | To shut down the proxy server, call the `close([destroyConnections], [callback])` function. For example: 322 | 323 | ```javascript 324 | server.close(true, () => { 325 | console.log('Proxy server was closed.'); 326 | }); 327 | ``` 328 | 329 | The `closeConnections` parameter indicates whether pending proxy connections should be forcibly closed. 330 | If it's `false`, the function will wait until all connections are closed, which can take a long time. 331 | If the `callback` parameter is omitted, the function returns a promise. 332 | 333 | 334 | ## Accessing the CONNECT response headers for proxy tunneling 335 | 336 | Some upstream proxy providers might include valuable debugging information in the CONNECT response 337 | headers when establishing the proxy tunnel, for they may not modify future data in the tunneled 338 | connection. 339 | 340 | The proxy server would emit a `tunnelConnectResponded` event for exposing such information, where 341 | the parameter types of the event callback are described in [Node.js's documentation][1]. Example: 342 | 343 | [1]: https://nodejs.org/api/http.html#http_event_connect 344 | 345 | ```javascript 346 | server.on('tunnelConnectResponded', ({ proxyChainId, response, socket, head, customTag }) => { 347 | console.log(`CONNECT response headers received: ${response.headers}`); 348 | }); 349 | ``` 350 | 351 | Alternatively a [helper function](##helper-functions) may be used: 352 | 353 | ```javascript 354 | listenConnectAnonymizedProxy(anonymizedProxyUrl, ({ response, socket, head }) => { 355 | console.log(`CONNECT response headers received: ${response.headers}`); 356 | }); 357 | ``` 358 | 359 | You can also listen to CONNECT requests that receive response with status code different from 200. 360 | The proxy server would emit a `tunnelConnectFailed` event. 361 | 362 | ```javascript 363 | server.on('tunnelConnectFailed', ({ proxyChainId, response, socket, head, customTag }) => { 364 | console.log(`CONNECT response failed with status code: ${response.statusCode}`); 365 | }); 366 | ``` 367 | 368 | ## Helper functions 369 | 370 | The package also provides several utility functions. 371 | 372 | 373 | ### `anonymizeProxy({ url, port }, callback)` 374 | 375 | Parses and validates a HTTP/HTTPS proxy URL. If the proxy requires authentication, 376 | then the function starts an open local proxy server that forwards to the proxy. 377 | The port (on which the local proxy server will start) can be set via the `port` property of the first argument, if not provided, it will be chosen randomly. 378 | 379 | For HTTPS proxy with self-signed certificate, set `ignoreProxyCertificate` property of the first argument to `true` to ignore certificate errors in 380 | proxy requests. 381 | 382 | The function takes an optional callback that receives the anonymous proxy URL. 383 | If no callback is supplied, the function returns a promise that resolves to a String with 384 | anonymous proxy URL or the original URL if it was already anonymous. 385 | 386 | The following example shows how you can use a proxy with authentication 387 | from headless Chrome and [Puppeteer](https://github.com/GoogleChrome/puppeteer). 388 | For details, read this [blog post](https://blog.apify.com/how-to-make-headless-chrome-and-puppeteer-use-a-proxy-server-with-authentication-249a21a79212). 389 | 390 | ```javascript 391 | const puppeteer = require('puppeteer'); 392 | const proxyChain = require('proxy-chain'); 393 | 394 | (async() => { 395 | const oldProxyUrl = 'http://bob:password123@proxy.example.com:8000'; 396 | const newProxyUrl = await proxyChain.anonymizeProxy(oldProxyUrl); 397 | 398 | // Prints something like "http://127.0.0.1:45678" 399 | console.log(newProxyUrl); 400 | 401 | const browser = await puppeteer.launch({ 402 | args: [`--proxy-server=${newProxyUrl}`], 403 | }); 404 | 405 | // Do your magic here... 406 | const page = await browser.newPage(); 407 | await page.goto('https://www.example.com'); 408 | await page.screenshot({ path: 'example.png' }); 409 | await browser.close(); 410 | 411 | // Clean up 412 | await proxyChain.closeAnonymizedProxy(newProxyUrl, true); 413 | })(); 414 | ``` 415 | 416 | ### `closeAnonymizedProxy(anonymizedProxyUrl, closeConnections, callback)` 417 | 418 | Closes anonymous proxy previously started by `anonymizeProxy()`. 419 | If proxy was not found or was already closed, the function has no effect 420 | and its result is `false`. Otherwise the result is `true`. 421 | 422 | The `closeConnections` parameter indicates whether pending proxy connections are forcibly closed. 423 | If it's `false`, the function will wait until all connections are closed, which can take a long time. 424 | 425 | The function takes an optional callback that receives the result Boolean from the function. 426 | If callback is not provided, the function returns a promise instead. 427 | 428 | ### `createTunnel(proxyUrl, targetHost, options, callback)` 429 | 430 | Creates a TCP tunnel to `targetHost` that goes through a HTTP/HTTPS proxy server 431 | specified by the `proxyUrl` parameter. 432 | 433 | The optional `options` parameter is an object with the following properties: 434 | - `port: Number` - Enables specifying the local port to listen at. By default `0`, 435 | which means a random port will be selected. 436 | - `hostname: String` - Local hostname to listen at. By default `localhost`. 437 | - `ignoreProxyCertificate` - For HTTPS proxy, ignore certificate errors in proxy requests. Useful for proxy with self-signed certificate. By default `false`. 438 | - `verbose: Boolean` - If `true`, the functions logs a lot. By default `false`. 439 | 440 | The result of the function is a local endpoint in a form of `hostname:port`. 441 | All TCP connections made to the local endpoint will be tunneled through the proxy to the target host and port. 442 | For example, this is useful if you want to access a certain service from a specific IP address. 443 | 444 | The tunnel should be eventually closed by calling the `closeTunnel()` function. 445 | 446 | The `createTunnel()` function accepts an optional Node.js-style callback that receives the path to the local endpoint. 447 | If no callback is supplied, the function returns a promise that resolves to a String with 448 | the path to the local endpoint. 449 | 450 | For more information, read this [blog post](https://blog.apify.com/tunneling-arbitrary-protocols-over-http-proxy-with-static-ip-address-b3a2222191ff). 451 | 452 | Example: 453 | 454 | ```javascript 455 | const host = await createTunnel('http://bob:pass123@proxy.example.com:8000', 'service.example.com:356'); 456 | // Prints something like "localhost:56836" 457 | console.log(host); 458 | ``` 459 | 460 | ### `closeTunnel(tunnelString, closeConnections, callback)` 461 | 462 | Closes tunnel previously started by `createTunnel()`. 463 | The result value is `false` if the tunnel was not found or was already closed, otherwise it is `true`. 464 | 465 | The `closeConnections` parameter indicates whether pending connections are forcibly closed. 466 | If it's `false`, the function will wait until all connections are closed, which can take a long time. 467 | 468 | The function takes an optional callback that receives the result of the function. 469 | If the callback is not provided, the function returns a promise instead. 470 | 471 | ### `listenConnectAnonymizedProxy(anonymizedProxyUrl, tunnelConnectRespondedCallback)` 472 | 473 | Allows to configure a callback on the anonymized proxy URL for the CONNECT response headers. See the 474 | above section [Accessing the CONNECT response headers for proxy tunneling](#accessing-the-connect-response-headers-for-proxy-tunneling) 475 | for details. 476 | 477 | ### `redactUrl(url, passwordReplacement)` 478 | 479 | Takes a URL and hides the password from it. For example: 480 | 481 | ```javascript 482 | // Prints 'http://bob:@example.com' 483 | console.log(redactUrl('http://bob:pass123@example.com')); 484 | ``` 485 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import apifyTypescriptConfig from '@apify/eslint-config/ts.js'; 2 | 3 | // eslint-disable-next-line import/no-default-export 4 | export default [ 5 | { ignores: ['**/dist', 'test'] }, // Ignores need to happen first 6 | ...apifyTypescriptConfig, 7 | { 8 | languageOptions: { 9 | sourceType: 'module', 10 | 11 | parserOptions: { 12 | project: 'tsconfig.eslint.json', 13 | }, 14 | }, 15 | rules: { 16 | 'no-param-reassign': 'off', 17 | 'import/extensions': 'off', 18 | }, 19 | }, 20 | ]; 21 | -------------------------------------------------------------------------------- /examples/apify_proxy_tunnel.js: -------------------------------------------------------------------------------- 1 | const { createTunnel, closeTunnel, redactUrl } = require('proxy-chain'); 2 | 3 | // This example demonstrates how to create a tunnel via Apify's HTTP proxy service. 4 | // For details, see https://blog.apify.com/tunneling-arbitrary-protocols-over-http-proxy-with-static-ip-address-b3a2222191ff 5 | 6 | (async () => { 7 | // Select the proxy to tunnel through. Note that some proxies do not allow 8 | // HTTP traffic (port 80) over the HTTP CONNECT tunnel, or might not allow connection 9 | // to target on any other port than 80 (HTTP) or 443 (HTTPS). 10 | // You might want to try different proxy groups. 11 | const PROXY_URL = 'http://auto:@proxy.apify.com:8000'; 12 | 13 | // Target server to connect to. Here we use www.example.com and port 443 for HTTPS. 14 | // You can use any other host and port. 15 | const TARGET_HOST = 'www.example.com:443'; 16 | 17 | // Create tunnel for the service, this call will start local tunnel and 18 | // return a string in format localhost:. 19 | // Here we set "port" to 9999, but you can use 0 to get a random port. 20 | // The "verbose" option causes a lot of logging 21 | const tunnelInfo = await createTunnel(PROXY_URL, TARGET_HOST, { port: 9999, verbose: true }); 22 | 23 | console.log(`Tunnel to ${TARGET_HOST} via ${redactUrl(PROXY_URL)} established at ${tunnelInfo}...`); 24 | 25 | // Here we assume por 443 from above, otherwise the service will not be accessible via HTTPS! 26 | console.log(`To test it, you can run: curl --verbose https://${tunnelInfo}`); 27 | 28 | // Wait forever... 29 | await new Promise(() => {}); 30 | 31 | // Normally, you'd also want to close the tunnel and all open connections 32 | await closeTunnel(tunnelInfo, true); 33 | })(); 34 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types'; 2 | 3 | // eslint-disable-next-line import/no-default-export 4 | export default (): Config.InitialOptions => ({ 5 | verbose: true, 6 | preset: 'ts-jest', 7 | testEnvironment: 'node', 8 | testRunner: 'jest-circus/runner', 9 | testTimeout: 20_000, 10 | collectCoverage: true, 11 | collectCoverageFrom: [ 12 | '**/src/**/*.ts', 13 | '**/src/**/*.js', 14 | '!**/node_modules/**', 15 | ], 16 | maxWorkers: 3, 17 | globals: { 18 | 'ts-jest': { 19 | tsconfig: '/test/tsconfig.json', 20 | }, 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "proxy-chain", 3 | "version": "2.5.9", 4 | "description": "Node.js implementation of a proxy server (think Squid) with support for SSL, authentication, upstream proxy chaining, and protocol tunneling.", 5 | "main": "dist/index.js", 6 | "keywords": [ 7 | "proxy", 8 | "squid", 9 | "apify", 10 | "tunnel", 11 | "puppeteer" 12 | ], 13 | "author": { 14 | "name": "Apify Technologies", 15 | "email": "support@apify.com", 16 | "url": "https://apify.com" 17 | }, 18 | "contributors": [ 19 | "Jan Curn " 20 | ], 21 | "license": "Apache-2.0", 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/apify/proxy-chain" 25 | }, 26 | "bugs": { 27 | "url": "https://github.com/apify/proxy-chain/issues" 28 | }, 29 | "homepage": "https://blog.apify.com/how-to-make-headless-chrome-and-puppeteer-use-a-proxy-server-with-authentication-249a21a79212", 30 | "files": [ 31 | "dist" 32 | ], 33 | "scripts": { 34 | "build:watch": "tsc -w", 35 | "build": "tsc", 36 | "clean": "rimraf dist", 37 | "prepublishOnly": "npm run build", 38 | "local-proxy": "node ./dist/run_locally.js", 39 | "test": "nyc cross-env NODE_OPTIONS=--insecure-http-parser mocha --bail", 40 | "lint": "eslint .", 41 | "lint:fix": "eslint . --fix" 42 | }, 43 | "engines": { 44 | "node": ">=14" 45 | }, 46 | "devDependencies": { 47 | "@apify/eslint-config": "^1.0.0", 48 | "@apify/tsconfig": "^0.1.0", 49 | "@types/jest": "^28.1.2", 50 | "@types/node": "^18.8.3", 51 | "basic-auth": "^2.0.1", 52 | "basic-auth-parser": "^0.0.2", 53 | "body-parser": "^1.19.0", 54 | "chai": "^4.3.4", 55 | "cross-env": "^7.0.3", 56 | "eslint": "^9.18.0", 57 | "express": "^4.17.1", 58 | "faye-websocket": "^0.11.4", 59 | "got-scraping": "^3.2.4-beta.0", 60 | "isparta": "^4.1.1", 61 | "mocha": "^10.0.0", 62 | "nyc": "^15.1.0", 63 | "portastic": "^1.0.1", 64 | "proxy": "^1.0.2", 65 | "puppeteer": "^19.6.3", 66 | "request": "^2.88.2", 67 | "rimraf": "^4.1.2", 68 | "sinon": "^13.0.2", 69 | "sinon-stub-promise": "^4.0.0", 70 | "socksv5": "^0.0.6", 71 | "through": "^2.3.8", 72 | "ts-node": "^10.2.1", 73 | "typescript": "^4.4.3", 74 | "typescript-eslint": "^8.20.0", 75 | "underscore": "^1.13.1", 76 | "ws": "^8.2.2" 77 | }, 78 | "nyc": { 79 | "reporter": [ 80 | "text", 81 | "html", 82 | "lcov" 83 | ], 84 | "exclude": [ 85 | "**/test/**" 86 | ] 87 | }, 88 | "dependencies": { 89 | "socks": "^2.8.3", 90 | "socks-proxy-agent": "^8.0.3", 91 | "tslib": "^2.3.1" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/anonymize_proxy.ts: -------------------------------------------------------------------------------- 1 | import type { Buffer } from 'node:buffer'; 2 | import type http from 'node:http'; 3 | import type net from 'node:net'; 4 | import { URL } from 'node:url'; 5 | 6 | import { Server, SOCKS_PROTOCOLS } from './server'; 7 | import { nodeify } from './utils/nodeify'; 8 | 9 | // Dictionary, key is value returned from anonymizeProxy(), value is Server instance. 10 | const anonymizedProxyUrlToServer: Record = {}; 11 | 12 | export interface AnonymizeProxyOptions { 13 | url: string; 14 | port: number; 15 | ignoreProxyCertificate?: boolean; 16 | } 17 | 18 | /** 19 | * Parses and validates a HTTP proxy URL. If the proxy requires authentication, 20 | * or if it is an HTTPS proxy and `ignoreProxyCertificate` is `true`, then the function 21 | * starts an open local proxy server that forwards to the upstream proxy. 22 | */ 23 | export const anonymizeProxy = async ( 24 | options: string | AnonymizeProxyOptions, 25 | callback?: (error: Error | null) => void, 26 | ): Promise => { 27 | let proxyUrl: string; 28 | let port = 0; 29 | let ignoreProxyCertificate = false; 30 | 31 | if (typeof options === 'string') { 32 | proxyUrl = options; 33 | } else { 34 | proxyUrl = options.url; 35 | port = options.port; 36 | 37 | if (port < 0 || port > 65535) { 38 | throw new Error( 39 | 'Invalid "port" option: only values equals or between 0-65535 are valid', 40 | ); 41 | } 42 | 43 | if (options.ignoreProxyCertificate !== undefined) { 44 | ignoreProxyCertificate = options.ignoreProxyCertificate; 45 | } 46 | } 47 | 48 | const parsedProxyUrl = new URL(proxyUrl); 49 | if (!['http:', 'https:', ...SOCKS_PROTOCOLS].includes(parsedProxyUrl.protocol)) { 50 | throw new Error(`Invalid "proxyUrl" provided: URL must have one of the following protocols: "http", "https", ${SOCKS_PROTOCOLS.map((p) => `"${p.replace(':', '')}"`).join(', ')} (was "${parsedProxyUrl}")`); 51 | } 52 | 53 | // If upstream proxy requires no password or if there is no need to ignore HTTPS proxy cert errors, return it directly 54 | if (!parsedProxyUrl.username && !parsedProxyUrl.password && (!ignoreProxyCertificate || parsedProxyUrl.protocol !== 'https:')) { 55 | return nodeify(Promise.resolve(proxyUrl), callback); 56 | } 57 | 58 | let server: Server & { port: number }; 59 | 60 | const startServer = async () => { 61 | return Promise.resolve().then(async () => { 62 | server = new Server({ 63 | // verbose: true, 64 | port, 65 | host: '127.0.0.1', 66 | prepareRequestFunction: () => { 67 | return { 68 | requestAuthentication: false, 69 | upstreamProxyUrl: proxyUrl, 70 | ignoreUpstreamProxyCertificate: ignoreProxyCertificate, 71 | }; 72 | }, 73 | }) as Server & { port: number }; 74 | 75 | return server.listen(); 76 | }); 77 | }; 78 | 79 | const promise = startServer().then(() => { 80 | const url = `http://127.0.0.1:${server.port}`; 81 | anonymizedProxyUrlToServer[url] = server; 82 | return url; 83 | }); 84 | 85 | return nodeify(promise, callback); 86 | }; 87 | 88 | /** 89 | * Closes anonymous proxy previously started by `anonymizeProxy()`. 90 | * If proxy was not found or was already closed, the function has no effect 91 | * and its result if `false`. Otherwise the result is `true`. 92 | * @param closeConnections If true, pending proxy connections are forcibly closed. 93 | */ 94 | export const closeAnonymizedProxy = async ( 95 | anonymizedProxyUrl: string, 96 | closeConnections: boolean, 97 | callback?: (error: Error | null, result?: boolean) => void, 98 | ): Promise => { 99 | if (typeof anonymizedProxyUrl !== 'string') { 100 | throw new Error('The "anonymizedProxyUrl" parameter must be a string'); 101 | } 102 | 103 | const server = anonymizedProxyUrlToServer[anonymizedProxyUrl]; 104 | if (!server) { 105 | return nodeify(Promise.resolve(false), callback); 106 | } 107 | 108 | delete anonymizedProxyUrlToServer[anonymizedProxyUrl]; 109 | 110 | const promise = server.close(closeConnections).then(() => { 111 | return true; 112 | }); 113 | return nodeify(promise, callback); 114 | }; 115 | 116 | type Callback = ({ 117 | response, 118 | socket, 119 | head, 120 | }: { 121 | response: http.IncomingMessage; 122 | socket: net.Socket; 123 | head: Buffer; 124 | }) => void; 125 | 126 | /** 127 | * Add a callback on 'tunnelConnectResponded' Event in order to get headers from CONNECT tunnel to proxy 128 | * Useful for some proxies that are using headers to send information like ProxyMesh 129 | * @returns `true` if the callback is successfully configured, otherwise `false` (e.g. when an 130 | * invalid proxy URL is given). 131 | */ 132 | export const listenConnectAnonymizedProxy = ( 133 | anonymizedProxyUrl: string, 134 | tunnelConnectRespondedCallback: Callback, 135 | ): boolean => { 136 | const server = anonymizedProxyUrlToServer[anonymizedProxyUrl]; 137 | if (!server) { 138 | return false; 139 | } 140 | server.on('tunnelConnectResponded', ({ response, socket, head }) => { 141 | tunnelConnectRespondedCallback({ response, socket, head }); 142 | }); 143 | return true; 144 | }; 145 | -------------------------------------------------------------------------------- /src/chain.ts: -------------------------------------------------------------------------------- 1 | import type { Buffer } from 'node:buffer'; 2 | import type dns from 'node:dns'; 3 | import type { EventEmitter } from 'node:events'; 4 | import http from 'node:http'; 5 | import https from 'node:https'; 6 | import type { URL } from 'node:url'; 7 | 8 | import type { Socket } from './socket'; 9 | import { badGatewayStatusCodes, createCustomStatusHttpResponse, errorCodeToStatusCode } from './statuses'; 10 | import type { SocketWithPreviousStats } from './utils/count_target_bytes'; 11 | import { countTargetBytes } from './utils/count_target_bytes'; 12 | import { getBasicAuthorizationHeader } from './utils/get_basic'; 13 | 14 | interface Options { 15 | method: string; 16 | headers: Record; 17 | path?: string; 18 | localAddress?: string; 19 | family?: number; 20 | lookup?: typeof dns['lookup']; 21 | } 22 | 23 | export interface HandlerOpts { 24 | upstreamProxyUrlParsed: URL; 25 | ignoreUpstreamProxyCertificate: boolean; 26 | localAddress?: string; 27 | ipFamily?: number; 28 | dnsLookup?: typeof dns['lookup']; 29 | customTag?: unknown; 30 | } 31 | 32 | interface ChainOpts { 33 | request: { url?: string }; 34 | sourceSocket: Socket; 35 | head?: Buffer; 36 | handlerOpts: HandlerOpts; 37 | server: EventEmitter & { log: (connectionId: unknown, str: string) => void }; 38 | isPlain: boolean; 39 | } 40 | 41 | /** 42 | * Passes the traffic to upstream HTTP proxy server. 43 | * Client -> Apify -> Upstream -> Web 44 | * Client <- Apify <- Upstream <- Web 45 | */ 46 | export const chain = ( 47 | { 48 | request, 49 | sourceSocket, 50 | head, 51 | handlerOpts, 52 | server, 53 | isPlain, 54 | }: ChainOpts, 55 | ): void => { 56 | if (head && head.length > 0) { 57 | // HTTP/1.1 has no defined semantics when sending payload along with CONNECT and servers can reject the request. 58 | // HTTP/2 only says that subsequent DATA frames must be transferred after HEADERS has been sent. 59 | // HTTP/3 says that all DATA frames should be transferred (implies pre-HEADERS data). 60 | // 61 | // Let's go with the HTTP/3 behavior. 62 | // There are also clients that send payload along with CONNECT to save milliseconds apparently. 63 | // Beware of upstream proxy servers that send out valid CONNECT responses with diagnostic data such as IPs! 64 | sourceSocket.unshift(head); 65 | } 66 | 67 | const { proxyChainId } = sourceSocket; 68 | 69 | const { upstreamProxyUrlParsed: proxy, customTag } = handlerOpts; 70 | 71 | const options: Options = { 72 | method: 'CONNECT', 73 | path: request.url, 74 | headers: { 75 | host: request.url!, 76 | }, 77 | localAddress: handlerOpts.localAddress, 78 | family: handlerOpts.ipFamily, 79 | lookup: handlerOpts.dnsLookup, 80 | }; 81 | 82 | if (proxy.username || proxy.password) { 83 | options.headers['proxy-authorization'] = getBasicAuthorizationHeader(proxy); 84 | } 85 | 86 | const client = proxy.protocol === 'https:' 87 | ? https.request(proxy.origin, { 88 | ...options, 89 | rejectUnauthorized: !handlerOpts.ignoreUpstreamProxyCertificate, 90 | }) 91 | : http.request(proxy.origin, options); 92 | 93 | client.once('socket', (targetSocket: SocketWithPreviousStats) => { 94 | // Socket can be re-used by multiple requests. 95 | // That's why we need to track the previous stats. 96 | targetSocket.previousBytesRead = targetSocket.bytesRead; 97 | targetSocket.previousBytesWritten = targetSocket.bytesWritten; 98 | countTargetBytes(sourceSocket, targetSocket); 99 | }); 100 | 101 | client.on('connect', (response, targetSocket, clientHead) => { 102 | if (sourceSocket.readyState !== 'open') { 103 | // Sanity check, should never reach. 104 | targetSocket.destroy(); 105 | return; 106 | } 107 | 108 | targetSocket.on('error', (error) => { 109 | server.log(proxyChainId, `Chain Destination Socket Error: ${error.stack}`); 110 | 111 | sourceSocket.destroy(); 112 | }); 113 | 114 | sourceSocket.on('error', (error) => { 115 | server.log(proxyChainId, `Chain Source Socket Error: ${error.stack}`); 116 | 117 | targetSocket.destroy(); 118 | }); 119 | 120 | if (response.statusCode !== 200) { 121 | server.log(proxyChainId, `Failed to authenticate upstream proxy: ${response.statusCode}`); 122 | 123 | if (isPlain) { 124 | sourceSocket.end(); 125 | } else { 126 | const { statusCode } = response; 127 | const status = statusCode === 401 || statusCode === 407 128 | ? badGatewayStatusCodes.AUTH_FAILED 129 | : badGatewayStatusCodes.NON_200; 130 | 131 | sourceSocket.end(createCustomStatusHttpResponse(status, `UPSTREAM${statusCode}`)); 132 | } 133 | 134 | targetSocket.end(); 135 | 136 | server.emit('tunnelConnectFailed', { 137 | proxyChainId, 138 | response, 139 | customTag, 140 | socket: targetSocket, 141 | head: clientHead, 142 | }); 143 | 144 | return; 145 | } 146 | 147 | if (clientHead.length > 0) { 148 | // See comment above 149 | targetSocket.unshift(clientHead); 150 | } 151 | 152 | server.emit('tunnelConnectResponded', { 153 | proxyChainId, 154 | response, 155 | customTag, 156 | socket: targetSocket, 157 | head: clientHead, 158 | }); 159 | 160 | sourceSocket.write(isPlain ? '' : `HTTP/1.1 200 Connection Established\r\n\r\n`); 161 | 162 | sourceSocket.pipe(targetSocket); 163 | targetSocket.pipe(sourceSocket); 164 | 165 | // Once target socket closes forcibly, the source socket gets paused. 166 | // We need to enable flowing, otherwise the socket would remain open indefinitely. 167 | // Nothing would consume the data, we just want to close the socket. 168 | targetSocket.on('close', () => { 169 | sourceSocket.resume(); 170 | 171 | if (sourceSocket.writable) { 172 | sourceSocket.end(); 173 | } 174 | }); 175 | 176 | // Same here. 177 | sourceSocket.on('close', () => { 178 | targetSocket.resume(); 179 | 180 | if (targetSocket.writable) { 181 | targetSocket.end(); 182 | } 183 | }); 184 | }); 185 | 186 | client.on('error', (error: NodeJS.ErrnoException) => { 187 | server.log(proxyChainId, `Failed to connect to upstream proxy: ${error.stack}`); 188 | 189 | // The end socket may get connected after the client to proxy one gets disconnected. 190 | if (sourceSocket.readyState === 'open') { 191 | if (isPlain) { 192 | sourceSocket.end(); 193 | } else { 194 | const statusCode = errorCodeToStatusCode[error.code!] ?? badGatewayStatusCodes.GENERIC_ERROR; 195 | const response = createCustomStatusHttpResponse(statusCode, error.code ?? 'Upstream Closed Early'); 196 | sourceSocket.end(response); 197 | } 198 | } 199 | }); 200 | 201 | sourceSocket.on('error', () => { 202 | client.destroy(); 203 | }); 204 | 205 | // In case the client ends the socket too early 206 | sourceSocket.on('close', () => { 207 | client.destroy(); 208 | }); 209 | 210 | client.end(); 211 | }; 212 | -------------------------------------------------------------------------------- /src/chain_socks.ts: -------------------------------------------------------------------------------- 1 | import type { Buffer } from 'node:buffer'; 2 | import type { EventEmitter } from 'node:events'; 3 | import type http from 'node:http'; 4 | import type net from 'node:net'; 5 | import { URL } from 'node:url'; 6 | 7 | import { SocksClient, type SocksClientError, type SocksProxy } from 'socks'; 8 | 9 | import type { Socket } from './socket'; 10 | import { createCustomStatusHttpResponse, socksErrorMessageToStatusCode } from './statuses'; 11 | import { countTargetBytes } from './utils/count_target_bytes'; 12 | 13 | export interface HandlerOpts { 14 | upstreamProxyUrlParsed: URL; 15 | customTag?: unknown; 16 | } 17 | 18 | interface ChainSocksOpts { 19 | request: http.IncomingMessage, 20 | sourceSocket: Socket; 21 | head: Buffer; 22 | server: EventEmitter & { log: (connectionId: unknown, str: string) => void }; 23 | handlerOpts: HandlerOpts; 24 | } 25 | 26 | const socksProtocolToVersionNumber = (protocol: string): 4 | 5 => { 27 | switch (protocol) { 28 | case 'socks4:': 29 | case 'socks4a:': 30 | return 4; 31 | default: 32 | return 5; 33 | } 34 | }; 35 | 36 | /** 37 | * Client -> Apify (CONNECT) -> Upstream (SOCKS) -> Web 38 | * Client <- Apify (CONNECT) <- Upstream (SOCKS) <- Web 39 | */ 40 | export const chainSocks = async ({ 41 | request, 42 | sourceSocket, 43 | head, 44 | server, 45 | handlerOpts, 46 | }: ChainSocksOpts): Promise => { 47 | const { proxyChainId } = sourceSocket; 48 | 49 | const { hostname, port, username, password } = handlerOpts.upstreamProxyUrlParsed; 50 | 51 | const proxy: SocksProxy = { 52 | host: hostname, 53 | port: Number(port), 54 | type: socksProtocolToVersionNumber(handlerOpts.upstreamProxyUrlParsed.protocol), 55 | userId: decodeURIComponent(username), 56 | password: decodeURIComponent(password), 57 | }; 58 | 59 | if (head && head.length > 0) { 60 | // HTTP/1.1 has no defined semantics when sending payload along with CONNECT and servers can reject the request. 61 | // HTTP/2 only says that subsequent DATA frames must be transferred after HEADERS has been sent. 62 | // HTTP/3 says that all DATA frames should be transferred (implies pre-HEADERS data). 63 | // 64 | // Let's go with the HTTP/3 behavior. 65 | // There are also clients that send payload along with CONNECT to save milliseconds apparently. 66 | // Beware of upstream proxy servers that send out valid CONNECT responses with diagnostic data such as IPs! 67 | sourceSocket.unshift(head); 68 | } 69 | 70 | const url = new URL(`connect://${request.url}`); 71 | const destination = { 72 | port: Number(url.port), 73 | host: url.hostname, 74 | }; 75 | 76 | let targetSocket: net.Socket; 77 | 78 | try { 79 | const client = await SocksClient.createConnection({ 80 | proxy, 81 | command: 'connect', 82 | destination, 83 | }); 84 | targetSocket = client.socket; 85 | 86 | sourceSocket.write(`HTTP/1.1 200 Connection Established\r\n\r\n`); 87 | } catch (error) { 88 | const socksError = error as SocksClientError; 89 | server.log(proxyChainId, `Failed to connect to upstream SOCKS proxy ${socksError.stack}`); 90 | sourceSocket.end(createCustomStatusHttpResponse(socksErrorMessageToStatusCode(socksError.message), socksError.message)); 91 | return; 92 | } 93 | 94 | countTargetBytes(sourceSocket, targetSocket); 95 | 96 | sourceSocket.pipe(targetSocket); 97 | targetSocket.pipe(sourceSocket); 98 | 99 | // Once target socket closes forcibly, the source socket gets paused. 100 | // We need to enable flowing, otherwise the socket would remain open indefinitely. 101 | // Nothing would consume the data, we just want to close the socket. 102 | targetSocket.on('close', () => { 103 | sourceSocket.resume(); 104 | 105 | if (sourceSocket.writable) { 106 | sourceSocket.end(); 107 | } 108 | }); 109 | 110 | // Same here. 111 | sourceSocket.on('close', () => { 112 | targetSocket.resume(); 113 | 114 | if (targetSocket.writable) { 115 | targetSocket.end(); 116 | } 117 | }); 118 | 119 | targetSocket.on('error', (error) => { 120 | server.log(proxyChainId, `Chain SOCKS Destination Socket Error: ${error.stack}`); 121 | 122 | sourceSocket.destroy(); 123 | }); 124 | 125 | sourceSocket.on('error', (error) => { 126 | server.log(proxyChainId, `Chain SOCKS Source Socket Error: ${error.stack}`); 127 | 128 | targetSocket.destroy(); 129 | }); 130 | }; 131 | -------------------------------------------------------------------------------- /src/custom_connect.ts: -------------------------------------------------------------------------------- 1 | import type http from 'node:http'; 2 | import type net from 'node:net'; 3 | import { promisify } from 'node:util'; 4 | 5 | export const customConnect = async (socket: net.Socket, server: http.Server): Promise => { 6 | // `countTargetBytes(socket, socket)` is incorrect here since `socket` is not a target. 7 | // We would have to create a new stream and pipe traffic through that, 8 | // however this would also increase CPU usage. 9 | // Also, counting bytes here is not correct since we don't know how the response is generated 10 | // (whether any additional sockets are used). 11 | 12 | const asyncWrite = promisify(socket.write).bind(socket); 13 | await asyncWrite('HTTP/1.1 200 Connection Established\r\n\r\n'); 14 | server.emit('connection', socket); 15 | 16 | return new Promise((resolve) => { 17 | if (socket.destroyed) { 18 | resolve(); 19 | return; 20 | } 21 | 22 | socket.once('close', () => { 23 | resolve(); 24 | }); 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /src/custom_response.ts: -------------------------------------------------------------------------------- 1 | import type { Buffer } from 'node:buffer'; 2 | import type http from 'node:http'; 3 | 4 | export interface CustomResponse { 5 | statusCode?: number; 6 | headers?: Record; 7 | body?: string | Buffer; 8 | encoding?: BufferEncoding; 9 | } 10 | 11 | export interface HandlerOpts { 12 | customResponseFunction: () => CustomResponse | Promise, 13 | } 14 | 15 | export const handleCustomResponse = async ( 16 | _request: http.IncomingMessage, 17 | response: http.ServerResponse, 18 | handlerOpts: HandlerOpts, 19 | ): Promise => { 20 | const { customResponseFunction } = handlerOpts; 21 | if (!customResponseFunction) { 22 | throw new Error('The "customResponseFunction" option is required'); 23 | } 24 | 25 | const customResponse = await customResponseFunction(); 26 | 27 | if (typeof customResponse !== 'object' || customResponse === null) { 28 | throw new Error('The user-provided "customResponseFunction" must return an object.'); 29 | } 30 | 31 | response.statusCode = customResponse.statusCode || 200; 32 | 33 | if (customResponse.headers) { 34 | for (const [key, value] of Object.entries(customResponse.headers)) { 35 | response.setHeader(key, value as string); 36 | } 37 | } 38 | 39 | response.end(customResponse.body, customResponse.encoding!); 40 | }; 41 | -------------------------------------------------------------------------------- /src/direct.ts: -------------------------------------------------------------------------------- 1 | import type { Buffer } from 'node:buffer'; 2 | import type dns from 'node:dns'; 3 | import type { EventEmitter } from 'node:events'; 4 | import net from 'node:net'; 5 | import { URL } from 'node:url'; 6 | 7 | import type { Socket } from './socket'; 8 | import { countTargetBytes } from './utils/count_target_bytes'; 9 | 10 | export interface HandlerOpts { 11 | localAddress?: string; 12 | ipFamily?: number; 13 | dnsLookup?: typeof dns['lookup']; 14 | } 15 | 16 | interface DirectOpts { 17 | request: { url?: string }; 18 | sourceSocket: Socket; 19 | head: Buffer; 20 | server: EventEmitter & { log: (connectionId: unknown, str: string) => void }; 21 | handlerOpts: HandlerOpts; 22 | } 23 | 24 | /** 25 | * Directly connects to the target. 26 | * Client -> Apify (CONNECT) -> Web 27 | * Client <- Apify (CONNECT) <- Web 28 | */ 29 | export const direct = ( 30 | { 31 | request, 32 | sourceSocket, 33 | head, 34 | server, 35 | handlerOpts, 36 | }: DirectOpts, 37 | ): void => { 38 | const url = new URL(`connect://${request.url}`); 39 | 40 | if (!url.hostname) { 41 | throw new Error('Missing CONNECT hostname'); 42 | } 43 | 44 | if (!url.port) { 45 | throw new Error('Missing CONNECT port'); 46 | } 47 | 48 | if (head.length > 0) { 49 | // See comment in chain.ts 50 | sourceSocket.unshift(head); 51 | } 52 | 53 | const options = { 54 | port: Number(url.port), 55 | host: url.hostname, 56 | localAddress: handlerOpts.localAddress, 57 | family: handlerOpts.ipFamily, 58 | lookup: handlerOpts.dnsLookup, 59 | }; 60 | 61 | if (options.host[0] === '[') { 62 | options.host = options.host.slice(1, -1); 63 | } 64 | 65 | const targetSocket = net.createConnection(options, () => { 66 | try { 67 | sourceSocket.write(`HTTP/1.1 200 Connection Established\r\n\r\n`); 68 | } catch (error) { 69 | sourceSocket.destroy(error as Error); 70 | } 71 | }); 72 | 73 | countTargetBytes(sourceSocket, targetSocket); 74 | 75 | sourceSocket.pipe(targetSocket); 76 | targetSocket.pipe(sourceSocket); 77 | 78 | // Once target socket closes forcibly, the source socket gets paused. 79 | // We need to enable flowing, otherwise the socket would remain open indefinitely. 80 | // Nothing would consume the data, we just want to close the socket. 81 | targetSocket.on('close', () => { 82 | sourceSocket.resume(); 83 | 84 | if (sourceSocket.writable) { 85 | sourceSocket.end(); 86 | } 87 | }); 88 | 89 | // Same here. 90 | sourceSocket.on('close', () => { 91 | targetSocket.resume(); 92 | 93 | if (targetSocket.writable) { 94 | targetSocket.end(); 95 | } 96 | }); 97 | 98 | const { proxyChainId } = sourceSocket; 99 | 100 | targetSocket.on('error', (error) => { 101 | server.log(proxyChainId, `Direct Destination Socket Error: ${error.stack}`); 102 | 103 | sourceSocket.destroy(); 104 | }); 105 | 106 | sourceSocket.on('error', (error) => { 107 | server.log(proxyChainId, `Direct Source Socket Error: ${error.stack}`); 108 | 109 | targetSocket.destroy(); 110 | }); 111 | }; 112 | -------------------------------------------------------------------------------- /src/forward.ts: -------------------------------------------------------------------------------- 1 | import type dns from 'node:dns'; 2 | import http from 'node:http'; 3 | import https from 'node:https'; 4 | import stream from 'node:stream'; 5 | import type { URL } from 'node:url'; 6 | import util from 'node:util'; 7 | 8 | import { badGatewayStatusCodes, errorCodeToStatusCode } from './statuses'; 9 | import type { SocketWithPreviousStats } from './utils/count_target_bytes'; 10 | import { countTargetBytes } from './utils/count_target_bytes'; 11 | import { getBasicAuthorizationHeader } from './utils/get_basic'; 12 | import { validHeadersOnly } from './utils/valid_headers_only'; 13 | 14 | const pipeline = util.promisify(stream.pipeline); 15 | 16 | interface Options { 17 | method: string; 18 | headers: string[]; 19 | insecureHTTPParser: boolean; 20 | path?: string; 21 | localAddress?: string; 22 | family?: number; 23 | lookup?: typeof dns['lookup']; 24 | } 25 | 26 | export interface HandlerOpts { 27 | upstreamProxyUrlParsed: URL; 28 | ignoreUpstreamProxyCertificate: boolean; 29 | localAddress?: string; 30 | ipFamily?: number; 31 | dnsLookup?: typeof dns['lookup']; 32 | } 33 | 34 | /** 35 | * The request is read from the client and is resent. 36 | * This is similar to Direct / Chain, however it uses the CONNECT protocol instead. 37 | * Forward uses standard HTTP methods. 38 | * 39 | * ``` 40 | * Client -> Apify (HTTP) -> Web 41 | * Client <- Apify (HTTP) <- Web 42 | * ``` 43 | * 44 | * or 45 | * 46 | * ``` 47 | * Client -> Apify (HTTP) -> Upstream (HTTP) -> Web 48 | * Client <- Apify (HTTP) <- Upstream (HTTP) <- Web 49 | * ``` 50 | */ 51 | export const forward = async ( 52 | request: http.IncomingMessage, 53 | response: http.ServerResponse, 54 | handlerOpts: HandlerOpts, 55 | // eslint-disable-next-line no-async-promise-executor 56 | ): Promise => new Promise(async (resolve, reject) => { 57 | const proxy = handlerOpts.upstreamProxyUrlParsed; 58 | const origin = proxy ? proxy.origin : request.url; 59 | 60 | const options: Options = { 61 | method: request.method!, 62 | headers: validHeadersOnly(request.rawHeaders), 63 | insecureHTTPParser: true, 64 | localAddress: handlerOpts.localAddress, 65 | family: handlerOpts.ipFamily, 66 | lookup: handlerOpts.dnsLookup, 67 | }; 68 | 69 | // In case of proxy the path needs to be an absolute URL 70 | if (proxy) { 71 | options.path = request.url; 72 | 73 | try { 74 | if (proxy.username || proxy.password) { 75 | options.headers.push('proxy-authorization', getBasicAuthorizationHeader(proxy)); 76 | } 77 | } catch (error) { 78 | reject(error); 79 | return; 80 | } 81 | } 82 | 83 | const requestCallback = async (clientResponse: http.IncomingMessage) => { 84 | try { 85 | // This is necessary to prevent Node.js throwing an error 86 | let statusCode = clientResponse.statusCode!; 87 | if (statusCode < 100 || statusCode > 999) { 88 | statusCode = badGatewayStatusCodes.STATUS_CODE_OUT_OF_RANGE; 89 | } 90 | 91 | // 407 is handled separately 92 | if (clientResponse.statusCode === 407) { 93 | reject(new Error('407 Proxy Authentication Required')); 94 | return; 95 | } 96 | 97 | response.writeHead( 98 | statusCode, 99 | clientResponse.statusMessage, 100 | validHeadersOnly(clientResponse.rawHeaders), 101 | ); 102 | 103 | // `pipeline` automatically handles all the events and data 104 | await pipeline( 105 | clientResponse, 106 | response, 107 | ); 108 | 109 | resolve(); 110 | } catch { 111 | // Client error, pipeline already destroys the streams, ignore. 112 | resolve(); 113 | } 114 | }; 115 | 116 | // We have to force cast `options` because @types/node doesn't support an array. 117 | const client = origin!.startsWith('https:') 118 | ? https.request(origin!, { 119 | ...options as unknown as https.RequestOptions, 120 | rejectUnauthorized: handlerOpts.upstreamProxyUrlParsed ? !handlerOpts.ignoreUpstreamProxyCertificate : undefined, 121 | }, requestCallback) 122 | 123 | : http.request(origin!, options as unknown as http.RequestOptions, requestCallback); 124 | 125 | client.once('socket', (socket: SocketWithPreviousStats) => { 126 | // Socket can be re-used by multiple requests. 127 | // That's why we need to track the previous stats. 128 | socket.previousBytesRead = socket.bytesRead; 129 | socket.previousBytesWritten = socket.bytesWritten; 130 | countTargetBytes(request.socket, socket, (handler) => response.once('close', handler)); 131 | }); 132 | 133 | // Can't use pipeline here as it automatically destroys the streams 134 | request.pipe(client); 135 | client.on('error', (error: NodeJS.ErrnoException) => { 136 | if (response.headersSent) { 137 | return; 138 | } 139 | 140 | const statusCode = errorCodeToStatusCode[error.code!] ?? badGatewayStatusCodes.GENERIC_ERROR; 141 | 142 | response.statusCode = !proxy && error.code === 'ENOTFOUND' ? 404 : statusCode; 143 | response.setHeader('content-type', 'text/plain; charset=utf-8'); 144 | response.end(http.STATUS_CODES[response.statusCode]); 145 | 146 | resolve(); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /src/forward_socks.ts: -------------------------------------------------------------------------------- 1 | import http from 'node:http'; 2 | import stream from 'node:stream'; 3 | import type { URL } from 'node:url'; 4 | import util from 'node:util'; 5 | 6 | import { SocksProxyAgent } from 'socks-proxy-agent'; 7 | 8 | import { badGatewayStatusCodes, errorCodeToStatusCode } from './statuses'; 9 | import { countTargetBytes } from './utils/count_target_bytes'; 10 | import { validHeadersOnly } from './utils/valid_headers_only'; 11 | 12 | const pipeline = util.promisify(stream.pipeline); 13 | 14 | interface Options { 15 | method: string; 16 | headers: string[]; 17 | insecureHTTPParser: boolean; 18 | path?: string; 19 | localAddress?: string; 20 | agent: http.Agent; 21 | } 22 | 23 | export interface HandlerOpts { 24 | upstreamProxyUrlParsed: URL; 25 | localAddress?: string; 26 | } 27 | 28 | /** 29 | * ``` 30 | * Client -> Apify (HTTP) -> Upstream (SOCKS) -> Web 31 | * Client <- Apify (HTTP) <- Upstream (SOCKS) <- Web 32 | * ``` 33 | */ 34 | export const forwardSocks = async ( 35 | request: http.IncomingMessage, 36 | response: http.ServerResponse, 37 | handlerOpts: HandlerOpts, 38 | // eslint-disable-next-line no-async-promise-executor 39 | ): Promise => new Promise(async (resolve, reject) => { 40 | const agent = new SocksProxyAgent(handlerOpts.upstreamProxyUrlParsed); 41 | 42 | const options: Options = { 43 | method: request.method!, 44 | headers: validHeadersOnly(request.rawHeaders), 45 | insecureHTTPParser: true, 46 | localAddress: handlerOpts.localAddress, 47 | agent, 48 | }; 49 | 50 | // Only handling "http" here - since everything else is handeled by tunnelSocks. 51 | // We have to force cast `options` because @types/node doesn't support an array. 52 | const client = http.request(request.url!, options as unknown as http.ClientRequestArgs, async (clientResponse) => { 53 | try { 54 | // This is necessary to prevent Node.js throwing an error 55 | let statusCode = clientResponse.statusCode!; 56 | if (statusCode < 100 || statusCode > 999) { 57 | statusCode = badGatewayStatusCodes.STATUS_CODE_OUT_OF_RANGE; 58 | } 59 | 60 | // 407 is handled separately 61 | if (clientResponse.statusCode === 407) { 62 | reject(new Error('407 Proxy Authentication Required')); 63 | return; 64 | } 65 | 66 | response.writeHead( 67 | statusCode, 68 | clientResponse.statusMessage, 69 | validHeadersOnly(clientResponse.rawHeaders), 70 | ); 71 | 72 | // `pipeline` automatically handles all the events and data 73 | await pipeline( 74 | clientResponse, 75 | response, 76 | ); 77 | 78 | resolve(); 79 | } catch { 80 | // Client error, pipeline already destroys the streams, ignore. 81 | resolve(); 82 | } 83 | }); 84 | 85 | client.once('socket', (socket) => { 86 | countTargetBytes(request.socket, socket); 87 | }); 88 | 89 | // Can't use pipeline here as it automatically destroys the streams 90 | request.pipe(client); 91 | client.on('error', (error: NodeJS.ErrnoException) => { 92 | if (response.headersSent) { 93 | return; 94 | } 95 | 96 | const statusCode = errorCodeToStatusCode[error.code!] ?? badGatewayStatusCodes.GENERIC_ERROR; 97 | 98 | response.statusCode = statusCode; 99 | response.setHeader('content-type', 'text/plain; charset=utf-8'); 100 | response.end(http.STATUS_CODES[response.statusCode]); 101 | 102 | resolve(); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './request_error'; 2 | export * from './server'; 3 | export * from './utils/redact_url'; 4 | export * from './anonymize_proxy'; 5 | export * from './tcp_tunnel_tools'; 6 | 7 | export { CustomResponse } from './custom_response'; 8 | -------------------------------------------------------------------------------- /src/request_error.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents custom request error. The message is emitted as HTTP response 3 | * with a specific HTTP code and headers. 4 | * If this error is thrown from the `prepareRequestFunction` function, 5 | * the message and status code is sent to client. 6 | * By default, the response will have Content-Type: text/plain 7 | * and for the 407 status the Proxy-Authenticate header will be added. 8 | */ 9 | export class RequestError extends Error { 10 | constructor( 11 | message: string, 12 | public statusCode: number, 13 | public headers?: Record, 14 | ) { 15 | super(message); 16 | this.name = RequestError.name; 17 | 18 | Error.captureStackTrace(this, RequestError); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-use-before-define */ 2 | import { Buffer } from 'node:buffer'; 3 | import type dns from 'node:dns'; 4 | import { EventEmitter } from 'node:events'; 5 | import http from 'node:http'; 6 | import type net from 'node:net'; 7 | import { URL } from 'node:url'; 8 | import util from 'node:util'; 9 | 10 | import type { HandlerOpts as ChainOpts } from './chain'; 11 | import { chain } from './chain'; 12 | import { chainSocks } from './chain_socks'; 13 | import { customConnect } from './custom_connect'; 14 | import type { HandlerOpts as CustomResponseOpts } from './custom_response'; 15 | import { handleCustomResponse } from './custom_response'; 16 | import { direct } from './direct'; 17 | import type { HandlerOpts as ForwardOpts } from './forward'; 18 | import { forward } from './forward'; 19 | import { forwardSocks } from './forward_socks'; 20 | import { RequestError } from './request_error'; 21 | import type { Socket } from './socket'; 22 | import { badGatewayStatusCodes } from './statuses'; 23 | import { getTargetStats } from './utils/count_target_bytes'; 24 | import { nodeify } from './utils/nodeify'; 25 | import { normalizeUrlPort } from './utils/normalize_url_port'; 26 | import { parseAuthorizationHeader } from './utils/parse_authorization_header'; 27 | import { redactUrl } from './utils/redact_url'; 28 | 29 | export const SOCKS_PROTOCOLS = ['socks:', 'socks4:', 'socks4a:', 'socks5:', 'socks5h:']; 30 | 31 | // TODO: 32 | // - Implement this requirement from rfc7230 33 | // "A proxy MUST forward unrecognized header fields unless the field-name 34 | // is listed in the Connection header field (Section 6.1) or the proxy 35 | // is specifically configured to block, or otherwise transform, such 36 | // fields. Other recipients SHOULD ignore unrecognized header fields. 37 | // These requirements allow HTTP's functionality to be enhanced without 38 | // requiring prior update of deployed intermediaries." 39 | 40 | const DEFAULT_AUTH_REALM = 'ProxyChain'; 41 | const DEFAULT_PROXY_SERVER_PORT = 8000; 42 | 43 | export type ConnectionStats = { 44 | srcTxBytes: number; 45 | srcRxBytes: number; 46 | trgTxBytes: number | null; 47 | trgRxBytes: number | null; 48 | }; 49 | 50 | type HandlerOpts = { 51 | server: Server; 52 | id: number; 53 | srcRequest: http.IncomingMessage; 54 | srcResponse: http.ServerResponse | null; 55 | srcHead: Buffer | null; 56 | trgParsed: URL | null; 57 | upstreamProxyUrlParsed: URL | null; 58 | ignoreUpstreamProxyCertificate: boolean; 59 | isHttp: boolean; 60 | customResponseFunction?: CustomResponseOpts['customResponseFunction'] | null; 61 | customConnectServer?: http.Server | null; 62 | localAddress?: string; 63 | ipFamily?: number; 64 | dnsLookup?: typeof dns['lookup']; 65 | customTag?: unknown; 66 | }; 67 | 68 | export type PrepareRequestFunctionOpts = { 69 | connectionId: number; 70 | request: http.IncomingMessage; 71 | username: string; 72 | password: string; 73 | hostname: string; 74 | port: number; 75 | isHttp: boolean; 76 | }; 77 | 78 | export type PrepareRequestFunctionResult = { 79 | customResponseFunction?: CustomResponseOpts['customResponseFunction']; 80 | customConnectServer?: http.Server | null; 81 | requestAuthentication?: boolean; 82 | failMsg?: string; 83 | upstreamProxyUrl?: string | null; 84 | ignoreUpstreamProxyCertificate?: boolean; 85 | localAddress?: string; 86 | ipFamily?: number; 87 | dnsLookup?: typeof dns['lookup']; 88 | customTag?: unknown; 89 | }; 90 | 91 | type Promisable = T | Promise; 92 | export type PrepareRequestFunction = (opts: PrepareRequestFunctionOpts) => Promisable; 93 | 94 | /** 95 | * Represents the proxy server. 96 | * It emits the 'requestFailed' event on unexpected request errors, with the following parameter `{ error, request }`. 97 | * It emits the 'connectionClosed' event when connection to proxy server is closed, with parameter `{ connectionId, stats }`. 98 | */ 99 | export class Server extends EventEmitter { 100 | port: number; 101 | 102 | host?: string; 103 | 104 | prepareRequestFunction?: PrepareRequestFunction; 105 | 106 | authRealm: unknown; 107 | 108 | verbose: boolean; 109 | 110 | server: http.Server; 111 | 112 | lastHandlerId: number; 113 | 114 | stats: { httpRequestCount: number; connectRequestCount: number; }; 115 | 116 | connections: Map; 117 | 118 | /** 119 | * Initializes a new instance of Server class. 120 | * @param options 121 | * @param [options.port] Port where the server will listen. By default 8000. 122 | * @param [options.prepareRequestFunction] Custom function to authenticate proxy requests, 123 | * provide URL to upstream proxy or potentially provide a function that generates a custom response to HTTP requests. 124 | * It accepts a single parameter which is an object: 125 | * ``` 126 | * { 127 | * connectionId: symbol, 128 | * request: http.IncomingMessage, 129 | * username: string, 130 | * password: string, 131 | * hostname: string, 132 | * port: number, 133 | * isHttp: boolean, 134 | * } 135 | * ``` 136 | * and returns an object (or promise resolving to the object) with following form: 137 | * ``` 138 | * { 139 | * requestAuthentication: boolean, 140 | * upstreamProxyUrl: string, 141 | * customResponseFunction: Function, 142 | * } 143 | * ``` 144 | * If `upstreamProxyUrl` is a falsy value, no upstream proxy is used. 145 | * If `prepareRequestFunction` is not set, the proxy server will not require any authentication 146 | * and will not use any upstream proxy. 147 | * If `customResponseFunction` is set, it will be called to generate a custom response to the HTTP request. 148 | * It should not be used together with `upstreamProxyUrl`. 149 | * @param [options.authRealm] Realm used in the Proxy-Authenticate header and also in the 'Server' HTTP header. By default it's `ProxyChain`. 150 | * @param [options.verbose] If true, the server will output logs 151 | */ 152 | constructor(options: { 153 | port?: number, 154 | host?: string, 155 | prepareRequestFunction?: PrepareRequestFunction, 156 | verbose?: boolean, 157 | authRealm?: unknown, 158 | } = {}) { 159 | super(); 160 | 161 | if (options.port === undefined || options.port === null) { 162 | this.port = DEFAULT_PROXY_SERVER_PORT; 163 | } else { 164 | this.port = options.port; 165 | } 166 | 167 | this.host = options.host; 168 | this.prepareRequestFunction = options.prepareRequestFunction; 169 | this.authRealm = options.authRealm || DEFAULT_AUTH_REALM; 170 | this.verbose = !!options.verbose; 171 | 172 | this.server = http.createServer(); 173 | this.server.on('clientError', this.onClientError.bind(this)); 174 | this.server.on('request', this.onRequest.bind(this)); 175 | this.server.on('connect', this.onConnect.bind(this)); 176 | this.server.on('connection', this.onConnection.bind(this)); 177 | 178 | this.lastHandlerId = 0; 179 | this.stats = { 180 | httpRequestCount: 0, 181 | connectRequestCount: 0, 182 | }; 183 | 184 | this.connections = new Map(); 185 | } 186 | 187 | log(connectionId: unknown, str: string): void { 188 | if (this.verbose) { 189 | const logPrefix = connectionId != null ? `${String(connectionId)} | ` : ''; 190 | // eslint-disable-next-line no-console 191 | console.log(`ProxyServer[${this.port}]: ${logPrefix}${str}`); 192 | } 193 | } 194 | 195 | onClientError(err: NodeJS.ErrnoException, socket: Socket): void { 196 | this.log(socket.proxyChainId, `onClientError: ${err}`); 197 | 198 | // https://nodejs.org/api/http.html#http_event_clienterror 199 | if (err.code === 'ECONNRESET' || !socket.writable) { 200 | return; 201 | } 202 | 203 | this.sendSocketResponse(socket, 400, {}, 'Invalid request'); 204 | } 205 | 206 | /** 207 | * Assigns a unique ID to the socket and keeps the register up to date. 208 | * Needed for abrupt close of the server. 209 | */ 210 | registerConnection(socket: Socket): void { 211 | const unique = this.lastHandlerId++; 212 | 213 | socket.proxyChainId = unique; 214 | this.connections.set(unique, socket); 215 | 216 | socket.on('close', () => { 217 | this.emit('connectionClosed', { 218 | connectionId: unique, 219 | stats: this.getConnectionStats(unique), 220 | }); 221 | 222 | this.connections.delete(unique); 223 | }); 224 | // We have to manually destroy the socket if it timeouts. 225 | // This will prevent connections from leaking and close them properly. 226 | socket.on('timeout', () => { 227 | socket.destroy(); 228 | }); 229 | } 230 | 231 | /** 232 | * Handles incoming sockets, useful for error handling 233 | */ 234 | onConnection(socket: Socket): void { 235 | // https://github.com/nodejs/node/issues/23858 236 | if (!socket.remoteAddress) { 237 | socket.destroy(); 238 | return; 239 | } 240 | 241 | this.registerConnection(socket); 242 | 243 | // We need to consume socket errors, because the handlers are attached asynchronously. 244 | // See https://github.com/apify/proxy-chain/issues/53 245 | socket.on('error', (err) => { 246 | // Handle errors only if there's no other handler 247 | if (this.listenerCount('error') === 1) { 248 | this.log(socket.proxyChainId, `Source socket emitted error: ${err.stack || err}`); 249 | } 250 | }); 251 | } 252 | 253 | /** 254 | * Converts known errors to be instance of RequestError. 255 | */ 256 | normalizeHandlerError(error: NodeJS.ErrnoException): NodeJS.ErrnoException { 257 | if (error.message === 'Username contains an invalid colon') { 258 | return new RequestError('Invalid colon in username in upstream proxy credentials', badGatewayStatusCodes.AUTH_FAILED); 259 | } 260 | 261 | if (error.message === '407 Proxy Authentication Required') { 262 | return new RequestError('Invalid upstream proxy credentials', badGatewayStatusCodes.AUTH_FAILED); 263 | } 264 | 265 | return error; 266 | } 267 | 268 | /** 269 | * Handles normal HTTP request by forwarding it to target host or the upstream proxy. 270 | */ 271 | async onRequest(request: http.IncomingMessage, response: http.ServerResponse): Promise { 272 | try { 273 | const handlerOpts = await this.prepareRequestHandling(request); 274 | handlerOpts.srcResponse = response; 275 | 276 | const { proxyChainId } = request.socket as Socket; 277 | 278 | if (handlerOpts.customResponseFunction) { 279 | this.log(proxyChainId, 'Using handleCustomResponse()'); 280 | await handleCustomResponse(request, response, handlerOpts as CustomResponseOpts); 281 | return; 282 | } 283 | 284 | if (handlerOpts.upstreamProxyUrlParsed && SOCKS_PROTOCOLS.includes(handlerOpts.upstreamProxyUrlParsed.protocol)) { 285 | this.log(proxyChainId, 'Using forwardSocks()'); 286 | await forwardSocks(request, response, handlerOpts as ForwardOpts); 287 | return; 288 | } 289 | 290 | this.log(proxyChainId, 'Using forward()'); 291 | await forward(request, response, handlerOpts as ForwardOpts); 292 | } catch (error) { 293 | this.failRequest(request, this.normalizeHandlerError(error as NodeJS.ErrnoException)); 294 | } 295 | } 296 | 297 | /** 298 | * Handles HTTP CONNECT request by setting up a tunnel either to target host or to the upstream proxy. 299 | * @param request 300 | * @param socket 301 | * @param head The first packet of the tunneling stream (may be empty) 302 | */ 303 | async onConnect(request: http.IncomingMessage, socket: Socket, head: Buffer): Promise { 304 | try { 305 | const handlerOpts = await this.prepareRequestHandling(request); 306 | handlerOpts.srcHead = head; 307 | 308 | const data = { request, sourceSocket: socket, head, handlerOpts: handlerOpts as ChainOpts, server: this, isPlain: false }; 309 | 310 | if (handlerOpts.customConnectServer) { 311 | socket.unshift(head); // See chain.ts for why we do this 312 | await customConnect(socket, handlerOpts.customConnectServer); 313 | return; 314 | } 315 | 316 | if (handlerOpts.upstreamProxyUrlParsed) { 317 | if (SOCKS_PROTOCOLS.includes(handlerOpts.upstreamProxyUrlParsed.protocol)) { 318 | this.log(socket.proxyChainId, `Using chainSocks() => ${request.url}`); 319 | await chainSocks(data); 320 | return; 321 | } 322 | this.log(socket.proxyChainId, `Using chain() => ${request.url}`); 323 | chain(data); 324 | return; 325 | } 326 | 327 | this.log(socket.proxyChainId, `Using direct() => ${request.url}`); 328 | direct(data); 329 | } catch (error) { 330 | this.failRequest(request, this.normalizeHandlerError(error as NodeJS.ErrnoException)); 331 | } 332 | } 333 | 334 | /** 335 | * Prepares handler options from a request. 336 | * @see {prepareRequestHandling} 337 | */ 338 | getHandlerOpts(request: http.IncomingMessage): HandlerOpts { 339 | const handlerOpts: HandlerOpts = { 340 | server: this, 341 | id: (request.socket as Socket).proxyChainId!, 342 | srcRequest: request, 343 | srcHead: null, 344 | trgParsed: null, 345 | upstreamProxyUrlParsed: null, 346 | ignoreUpstreamProxyCertificate: false, 347 | isHttp: false, 348 | srcResponse: null, 349 | customResponseFunction: null, 350 | customConnectServer: null, 351 | }; 352 | 353 | this.log((request.socket as Socket).proxyChainId, `!!! Handling ${request.method} ${request.url} HTTP/${request.httpVersion}`); 354 | 355 | if (request.method === 'CONNECT') { 356 | // CONNECT server.example.com:80 HTTP/1.1 357 | try { 358 | handlerOpts.trgParsed = new URL(`connect://${request.url}`); 359 | } catch { 360 | throw new RequestError(`Target "${request.url}" could not be parsed`, 400); 361 | } 362 | 363 | if (!handlerOpts.trgParsed.hostname || !handlerOpts.trgParsed.port) { 364 | throw new RequestError(`Target "${request.url}" could not be parsed`, 400); 365 | } 366 | 367 | this.stats.connectRequestCount++; 368 | } else { 369 | // The request should look like: 370 | // GET http://server.example.com:80/some-path HTTP/1.1 371 | // Note that RFC 7230 says: 372 | // "When making a request to a proxy, other than a CONNECT or server-wide 373 | // OPTIONS request (as detailed below), a client MUST send the target 374 | // URI in absolute-form as the request-target" 375 | 376 | let parsed; 377 | try { 378 | parsed = new URL(request.url!); 379 | } catch { 380 | // If URL is invalid, throw HTTP 400 error 381 | throw new RequestError(`Target "${request.url}" could not be parsed`, 400); 382 | } 383 | 384 | // Only HTTP is supported, other protocols such as HTTP or FTP must use the CONNECT method 385 | if (parsed.protocol !== 'http:') { 386 | throw new RequestError(`Only HTTP protocol is supported (was ${parsed.protocol})`, 400); 387 | } 388 | 389 | handlerOpts.trgParsed = parsed; 390 | handlerOpts.isHttp = true; 391 | 392 | this.stats.httpRequestCount++; 393 | } 394 | 395 | return handlerOpts; 396 | } 397 | 398 | /** 399 | * Calls `this.prepareRequestFunction` with normalized options. 400 | * @param request 401 | * @param handlerOpts 402 | */ 403 | async callPrepareRequestFunction(request: http.IncomingMessage, handlerOpts: HandlerOpts): Promise { 404 | if (this.prepareRequestFunction) { 405 | const funcOpts: PrepareRequestFunctionOpts = { 406 | connectionId: (request.socket as Socket).proxyChainId!, 407 | request, 408 | username: '', 409 | password: '', 410 | hostname: handlerOpts.trgParsed!.hostname, 411 | port: normalizeUrlPort(handlerOpts.trgParsed!), 412 | isHttp: handlerOpts.isHttp, 413 | }; 414 | 415 | // Authenticate the request using a user function (if provided) 416 | const proxyAuth = request.headers['proxy-authorization']; 417 | if (proxyAuth) { 418 | const auth = parseAuthorizationHeader(proxyAuth); 419 | 420 | if (!auth) { 421 | throw new RequestError('Invalid "Proxy-Authorization" header', 400); 422 | } 423 | 424 | // https://datatracker.ietf.org/doc/html/rfc7617#page-3 425 | // Note that both scheme and parameter names are matched case- 426 | // insensitively. 427 | if (auth.type.toLowerCase() !== 'basic') { 428 | throw new RequestError('The "Proxy-Authorization" header must have the "Basic" type.', 400); 429 | } 430 | 431 | funcOpts.username = auth.username!; 432 | funcOpts.password = auth.password!; 433 | } 434 | 435 | const result = await this.prepareRequestFunction(funcOpts); 436 | return result ?? {}; 437 | } 438 | 439 | return {}; 440 | } 441 | 442 | /** 443 | * Authenticates a new request and determines upstream proxy URL using the user function. 444 | * Returns a promise resolving to an object that can be used to run a handler. 445 | * @param request 446 | */ 447 | async prepareRequestHandling(request: http.IncomingMessage): Promise { 448 | const handlerOpts = this.getHandlerOpts(request); 449 | const funcResult = await this.callPrepareRequestFunction(request, handlerOpts); 450 | 451 | handlerOpts.localAddress = funcResult.localAddress; 452 | handlerOpts.ipFamily = funcResult.ipFamily; 453 | handlerOpts.dnsLookup = funcResult.dnsLookup; 454 | handlerOpts.customConnectServer = funcResult.customConnectServer; 455 | handlerOpts.customTag = funcResult.customTag; 456 | 457 | // If not authenticated, request client to authenticate 458 | if (funcResult.requestAuthentication) { 459 | throw new RequestError(funcResult.failMsg || 'Proxy credentials required.', 407); 460 | } 461 | 462 | if (funcResult.upstreamProxyUrl) { 463 | try { 464 | handlerOpts.upstreamProxyUrlParsed = new URL(funcResult.upstreamProxyUrl); 465 | } catch (error) { 466 | throw new Error(`Invalid "upstreamProxyUrl" provided: ${error} (was "${funcResult.upstreamProxyUrl}"`); 467 | } 468 | 469 | if (!['http:', 'https:', ...SOCKS_PROTOCOLS].includes(handlerOpts.upstreamProxyUrlParsed.protocol)) { 470 | throw new Error(`Invalid "upstreamProxyUrl" provided: URL must have one of the following protocols: "http", "https", ${SOCKS_PROTOCOLS.map((p) => `"${p.replace(':', '')}"`).join(', ')} (was "${funcResult.upstreamProxyUrl}")`); 471 | } 472 | } 473 | 474 | if (funcResult.ignoreUpstreamProxyCertificate !== undefined) { 475 | handlerOpts.ignoreUpstreamProxyCertificate = funcResult.ignoreUpstreamProxyCertificate; 476 | } 477 | 478 | const { proxyChainId } = request.socket as Socket; 479 | 480 | if (funcResult.customResponseFunction) { 481 | this.log(proxyChainId, 'Using custom response function'); 482 | 483 | handlerOpts.customResponseFunction = funcResult.customResponseFunction; 484 | 485 | if (!handlerOpts.isHttp) { 486 | throw new Error('The "customResponseFunction" option can only be used for HTTP requests.'); 487 | } 488 | 489 | if (typeof (handlerOpts.customResponseFunction) !== 'function') { 490 | throw new Error('The "customResponseFunction" option must be a function.'); 491 | } 492 | } 493 | 494 | if (handlerOpts.upstreamProxyUrlParsed) { 495 | this.log(proxyChainId, `Using upstream proxy ${redactUrl(handlerOpts.upstreamProxyUrlParsed)}`); 496 | } 497 | 498 | return handlerOpts; 499 | } 500 | 501 | /** 502 | * Sends a HTTP error response to the client. 503 | * @param request 504 | * @param error 505 | */ 506 | failRequest(request: http.IncomingMessage, error: NodeJS.ErrnoException): void { 507 | const { proxyChainId } = request.socket as Socket; 508 | 509 | if (error.name === 'RequestError') { 510 | const typedError = error as RequestError; 511 | 512 | this.log(proxyChainId, `Request failed (status ${typedError.statusCode}): ${error.message}`); 513 | this.sendSocketResponse(request.socket, typedError.statusCode, typedError.headers, error.message); 514 | } else { 515 | this.log(proxyChainId, `Request failed with error: ${error.stack || error}`); 516 | this.sendSocketResponse(request.socket, 500, {}, 'Internal error in proxy server'); 517 | this.emit('requestFailed', { error, request }); 518 | } 519 | 520 | this.log(proxyChainId, 'Closing because request failed with error'); 521 | } 522 | 523 | /** 524 | * Sends a simple HTTP response to the client and forcibly closes the connection. 525 | * This invalidates the ServerResponse instance (if present). 526 | * We don't know the state of the response anyway. 527 | * Writing directly to the socket seems to be the easiest solution. 528 | * @param socket 529 | * @param statusCode 530 | * @param headers 531 | * @param message 532 | */ 533 | sendSocketResponse(socket: Socket, statusCode = 500, caseSensitiveHeaders = {}, message = ''): void { 534 | try { 535 | const headers = Object.fromEntries( 536 | Object.entries(caseSensitiveHeaders).map( 537 | ([name, value]) => [name.toLowerCase(), value], 538 | ), 539 | ); 540 | 541 | headers.connection = 'close'; 542 | headers.date = (new Date()).toUTCString(); 543 | headers['content-length'] = String(Buffer.byteLength(message)); 544 | 545 | headers.server = headers.server || this.authRealm; 546 | headers['content-type'] = headers['content-type'] || 'text/plain; charset=utf-8'; 547 | 548 | if (statusCode === 407 && !headers['proxy-authenticate']) { 549 | headers['proxy-authenticate'] = `Basic realm="${this.authRealm}"`; 550 | } 551 | 552 | let msg = `HTTP/1.1 ${statusCode} ${http.STATUS_CODES[statusCode] || 'Unknown Status Code'}\r\n`; 553 | for (const [key, value] of Object.entries(headers)) { 554 | msg += `${key}: ${value}\r\n`; 555 | } 556 | msg += `\r\n${message}`; 557 | 558 | // Unfortunately it's not possible to send RST in Node.js yet. 559 | // See https://github.com/nodejs/node/issues/27428 560 | socket.setTimeout(1000, () => { 561 | socket.destroy(); 562 | }); 563 | 564 | // This sends FIN, meaning we still can receive data. 565 | socket.end(msg); 566 | } catch (err) { 567 | this.log(socket.proxyChainId, `Unhandled error in sendResponse(), will be ignored: ${(err as Error).stack || err}`); 568 | } 569 | } 570 | 571 | /** 572 | * Starts listening at a port specified in the constructor. 573 | */ 574 | async listen(callback?: (error: NodeJS.ErrnoException | null) => void): Promise { 575 | const promise = new Promise((resolve, reject) => { 576 | // Unfortunately server.listen() is not a normal function that fails on error, 577 | // so we need this trickery 578 | const onError = (error: NodeJS.ErrnoException) => { 579 | this.log(null, `Listen failed: ${error}`); 580 | removeListeners(); 581 | reject(error); 582 | }; 583 | const onListening = () => { 584 | this.port = (this.server.address() as net.AddressInfo).port; 585 | this.log(null, 'Listening...'); 586 | removeListeners(); 587 | resolve(); 588 | }; 589 | const removeListeners = () => { 590 | this.server.removeListener('error', onError); 591 | this.server.removeListener('listening', onListening); 592 | }; 593 | 594 | this.server.on('error', onError); 595 | this.server.on('listening', onListening); 596 | this.server.listen(this.port, this.host); 597 | }); 598 | 599 | return nodeify(promise, callback); 600 | } 601 | 602 | /** 603 | * Gets array of IDs of all active connections. 604 | */ 605 | getConnectionIds(): number[] { 606 | return [...this.connections.keys()]; 607 | } 608 | 609 | /** 610 | * Gets data transfer statistics of a specific proxy connection. 611 | */ 612 | getConnectionStats(connectionId: number): ConnectionStats | undefined { 613 | const socket = this.connections.get(connectionId); 614 | if (!socket) return undefined; 615 | 616 | const targetStats = getTargetStats(socket); 617 | 618 | const result = { 619 | srcTxBytes: socket.bytesWritten, 620 | srcRxBytes: socket.bytesRead, 621 | trgTxBytes: targetStats.bytesWritten, 622 | trgRxBytes: targetStats.bytesRead, 623 | }; 624 | 625 | return result; 626 | } 627 | 628 | /** 629 | * Forcibly close a specific pending proxy connection. 630 | */ 631 | closeConnection(connectionId: number): void { 632 | this.log(null, 'Closing pending socket'); 633 | 634 | const socket = this.connections.get(connectionId); 635 | if (!socket) return; 636 | 637 | socket.destroy(); 638 | 639 | this.log(null, `Destroyed pending socket`); 640 | } 641 | 642 | /** 643 | * Forcibly closes pending proxy connections. 644 | */ 645 | closeConnections(): void { 646 | this.log(null, 'Closing pending sockets'); 647 | 648 | for (const socket of this.connections.values()) { 649 | socket.destroy(); 650 | } 651 | 652 | this.log(null, `Destroyed ${this.connections.size} pending sockets`); 653 | } 654 | 655 | /** 656 | * Closes the proxy server. 657 | * @param closeConnections If true, pending proxy connections are forcibly closed. 658 | */ 659 | async close(closeConnections: boolean, callback?: (error: NodeJS.ErrnoException | null) => void): Promise { 660 | if (typeof closeConnections === 'function') { 661 | callback = closeConnections; 662 | closeConnections = false; 663 | } 664 | 665 | if (closeConnections) { 666 | this.closeConnections(); 667 | } 668 | 669 | if (this.server) { 670 | const { server } = this; 671 | // @ts-expect-error Let's make sure we can't access the server anymore. 672 | this.server = null; 673 | const promise = util.promisify(server.close).bind(server)(); 674 | return nodeify(promise, callback); 675 | } 676 | 677 | return nodeify(Promise.resolve(), callback); 678 | } 679 | } 680 | -------------------------------------------------------------------------------- /src/socket.ts: -------------------------------------------------------------------------------- 1 | import type net from 'node:net'; 2 | import type tls from 'node:tls'; 3 | 4 | type AdditionalProps = { proxyChainId?: number }; 5 | 6 | export type Socket = net.Socket & AdditionalProps; 7 | export type TLSSocket = tls.TLSSocket & AdditionalProps; 8 | -------------------------------------------------------------------------------- /src/statuses.ts: -------------------------------------------------------------------------------- 1 | import { STATUS_CODES } from 'node:http'; 2 | 3 | type HttpStatusCode = number; 4 | 5 | export const badGatewayStatusCodes = { 6 | /** 7 | * Upstream has timed out. 8 | */ 9 | TIMEOUT: 504, 10 | /** 11 | * Upstream responded with non-200 status code. 12 | */ 13 | NON_200: 590, 14 | /** 15 | * Upstream respondend with status code different than 100-999. 16 | */ 17 | STATUS_CODE_OUT_OF_RANGE: 592, 18 | /** 19 | * DNS lookup failed - EAI_NODATA or EAI_NONAME. 20 | */ 21 | NOT_FOUND: 593, 22 | /** 23 | * Upstream refused connection. 24 | */ 25 | CONNECTION_REFUSED: 594, 26 | /** 27 | * Connection reset due to loss of connection or timeout. 28 | */ 29 | CONNECTION_RESET: 595, 30 | /** 31 | * Trying to write on a closed socket. 32 | */ 33 | BROKEN_PIPE: 596, 34 | /** 35 | * Incorrect upstream credentials. 36 | */ 37 | AUTH_FAILED: 597, 38 | /** 39 | * Generic upstream error. 40 | */ 41 | GENERIC_ERROR: 599, 42 | } as const; 43 | 44 | STATUS_CODES['590'] = 'Non Successful'; 45 | STATUS_CODES['592'] = 'Status Code Out Of Range'; 46 | STATUS_CODES['593'] = 'Not Found'; 47 | STATUS_CODES['594'] = 'Connection Refused'; 48 | STATUS_CODES['595'] = 'Connection Reset'; 49 | STATUS_CODES['596'] = 'Broken Pipe'; 50 | STATUS_CODES['597'] = 'Auth Failed'; 51 | STATUS_CODES['599'] = 'Upstream Error'; 52 | 53 | export const createCustomStatusHttpResponse = (statusCode: number, statusMessage: string, message = '') => { 54 | return [ 55 | `HTTP/1.1 ${statusCode} ${statusMessage || STATUS_CODES[statusCode] || 'Unknown Status Code'}`, 56 | 'Connection: close', 57 | `Date: ${(new Date()).toUTCString()}`, 58 | `Content-Length: ${Buffer.byteLength(message)}`, 59 | ``, 60 | message, 61 | ].join('\r\n'); 62 | }; 63 | 64 | // https://nodejs.org/api/errors.html#common-system-errors 65 | export const errorCodeToStatusCode: {[errorCode: string]: HttpStatusCode | undefined} = { 66 | ENOTFOUND: badGatewayStatusCodes.NOT_FOUND, 67 | ECONNREFUSED: badGatewayStatusCodes.CONNECTION_REFUSED, 68 | ECONNRESET: badGatewayStatusCodes.CONNECTION_RESET, 69 | EPIPE: badGatewayStatusCodes.BROKEN_PIPE, 70 | ETIMEDOUT: badGatewayStatusCodes.TIMEOUT, 71 | } as const; 72 | 73 | export const socksErrorMessageToStatusCode = (socksErrorMessage: string): typeof badGatewayStatusCodes[keyof typeof badGatewayStatusCodes] => { 74 | switch (socksErrorMessage) { 75 | case 'Proxy connection timed out': 76 | return badGatewayStatusCodes.TIMEOUT; 77 | case 'Socks5 Authentication failed': 78 | return badGatewayStatusCodes.AUTH_FAILED; 79 | default: 80 | return badGatewayStatusCodes.GENERIC_ERROR; 81 | } 82 | }; 83 | -------------------------------------------------------------------------------- /src/tcp_tunnel_tools.ts: -------------------------------------------------------------------------------- 1 | import net from 'node:net'; 2 | import { URL } from 'node:url'; 3 | 4 | import { chain } from './chain'; 5 | import { nodeify } from './utils/nodeify'; 6 | 7 | const runningServers: Record }> = {}; 8 | 9 | const getAddress = (server: net.Server) => { 10 | const { address: host, port, family } = server.address() as net.AddressInfo; 11 | 12 | if (family === 'IPv6') { 13 | return `[${host}]:${port}`; 14 | } 15 | 16 | return `${host}:${port}`; 17 | }; 18 | 19 | export async function createTunnel( 20 | proxyUrl: string, 21 | targetHost: string, 22 | options?: { 23 | verbose?: boolean; 24 | ignoreProxyCertificate?: boolean; 25 | }, 26 | callback?: (error: Error | null, result?: string) => void, 27 | ): Promise { 28 | const parsedProxyUrl = new URL(proxyUrl); 29 | if (!['http:', 'https:'].includes(parsedProxyUrl.protocol)) { 30 | throw new Error(`The proxy URL must have the "http" or "https" protocol (was "${proxyUrl}")`); 31 | } 32 | 33 | const url = new URL(`connect://${targetHost || ''}`); 34 | 35 | if (!url.hostname) { 36 | throw new Error('Missing target hostname'); 37 | } 38 | 39 | if (!url.port) { 40 | throw new Error('Missing target port'); 41 | } 42 | 43 | const verbose = options && options.verbose; 44 | 45 | const server: net.Server & { log?: (...args: unknown[]) => void } = net.createServer(); 46 | 47 | const log = (...args: unknown[]): void => { 48 | if (verbose) console.log(...args); 49 | }; 50 | 51 | server.log = log; 52 | 53 | server.on('connection', (sourceSocket) => { 54 | const remoteAddress = `${sourceSocket.remoteAddress}:${sourceSocket.remotePort}`; 55 | 56 | const { connections } = runningServers[getAddress(server)]; 57 | 58 | log(`new client connection from ${remoteAddress}`); 59 | 60 | sourceSocket.on('close', (hadError) => { 61 | connections.delete(sourceSocket); 62 | 63 | log(`connection from ${remoteAddress} closed, hadError=${hadError}`); 64 | }); 65 | 66 | connections.add(sourceSocket); 67 | 68 | chain({ 69 | request: { url: targetHost }, 70 | sourceSocket, 71 | handlerOpts: { 72 | upstreamProxyUrlParsed: parsedProxyUrl, 73 | ignoreUpstreamProxyCertificate: options?.ignoreProxyCertificate ?? false, 74 | }, 75 | server: server as net.Server & { log: typeof log }, 76 | isPlain: true, 77 | }); 78 | }); 79 | 80 | const promise = new Promise((resolve, reject) => { 81 | server.once('error', reject); 82 | 83 | // Let the system pick a random listening port 84 | server.listen(0, () => { 85 | const address = getAddress(server); 86 | 87 | server.off('error', reject); 88 | runningServers[address] = { server, connections: new Set() }; 89 | 90 | log('server listening to ', address); 91 | 92 | resolve(address); 93 | }); 94 | }); 95 | 96 | return nodeify(promise, callback); 97 | } 98 | 99 | export async function closeTunnel( 100 | serverPath: string, 101 | closeConnections: boolean | undefined, 102 | callback: (error: Error | null, result?: boolean) => void, 103 | ): Promise { 104 | const { hostname, port } = new URL(`tcp://${serverPath}`); 105 | if (!hostname) throw new Error('serverPath must contain hostname'); 106 | if (!port) throw new Error('serverPath must contain port'); 107 | 108 | const promise = new Promise((resolve) => { 109 | if (!runningServers[serverPath]) { 110 | resolve(false); 111 | return; 112 | } 113 | if (!closeConnections) { 114 | resolve(true); 115 | return; 116 | } 117 | for (const connection of runningServers[serverPath].connections) { 118 | connection.destroy(); 119 | } 120 | resolve(true); 121 | }) 122 | .then(async (serverExists) => new Promise((resolve) => { 123 | if (!serverExists) { 124 | resolve(false); 125 | return; 126 | } 127 | runningServers[serverPath].server.close(() => { 128 | delete runningServers[serverPath]; 129 | resolve(true); 130 | }); 131 | })); 132 | 133 | return nodeify(promise, callback); 134 | } 135 | -------------------------------------------------------------------------------- /src/utils/count_target_bytes.ts: -------------------------------------------------------------------------------- 1 | import type net from 'node:net'; 2 | 3 | const targetBytesWritten = Symbol('targetBytesWritten'); 4 | const targetBytesRead = Symbol('targetBytesRead'); 5 | const targets = Symbol('targets'); 6 | const calculateTargetStats = Symbol('calculateTargetStats'); 7 | 8 | type Stats = { bytesWritten: number | null, bytesRead: number | null }; 9 | 10 | /** 11 | * Socket object extended with previous read and written bytes. 12 | * Necessary due to target socket re-use. 13 | */ 14 | export type SocketWithPreviousStats = net.Socket & { previousBytesWritten?: number, previousBytesRead?: number }; 15 | 16 | interface Extras { 17 | [targetBytesWritten]: number; 18 | [targetBytesRead]: number; 19 | [targets]: Set; 20 | [calculateTargetStats]: () => Stats; 21 | } 22 | 23 | // @ts-expect-error TS is not aware that `source` is used in the assertion. 24 | // eslint-disable-next-line @typescript-eslint/no-empty-function 25 | function typeSocket(source: unknown): asserts source is net.Socket & Extras {} 26 | 27 | export const countTargetBytes = ( 28 | source: net.Socket, 29 | target: SocketWithPreviousStats, 30 | registerCloseHandler?: (handler: () => void) => void, 31 | ): void => { 32 | typeSocket(source); 33 | 34 | source[targetBytesWritten] = source[targetBytesWritten] || 0; 35 | source[targetBytesRead] = source[targetBytesRead] || 0; 36 | source[targets] = source[targets] || new Set(); 37 | 38 | source[targets].add(target); 39 | 40 | const closeHandler = () => { 41 | source[targetBytesWritten] += (target.bytesWritten - (target.previousBytesWritten || 0)); 42 | source[targetBytesRead] += (target.bytesRead - (target.previousBytesRead || 0)); 43 | source[targets].delete(target); 44 | }; 45 | if (!registerCloseHandler) { 46 | registerCloseHandler = (handler: () => void) => target.once('close', handler); 47 | } 48 | registerCloseHandler(closeHandler); 49 | 50 | if (!source[calculateTargetStats]) { 51 | source[calculateTargetStats] = () => { 52 | let bytesWritten = source[targetBytesWritten]; 53 | let bytesRead = source[targetBytesRead]; 54 | 55 | for (const socket of source[targets]) { 56 | bytesWritten += (socket.bytesWritten - (socket.previousBytesWritten || 0)); 57 | bytesRead += (socket.bytesRead - (socket.previousBytesRead || 0)); 58 | } 59 | 60 | return { 61 | bytesWritten, 62 | bytesRead, 63 | }; 64 | }; 65 | } 66 | }; 67 | 68 | export const getTargetStats = (socket: net.Socket): Stats => { 69 | typeSocket(socket); 70 | 71 | if (socket[calculateTargetStats]) { 72 | return socket[calculateTargetStats](); 73 | } 74 | 75 | return { 76 | bytesWritten: null, 77 | bytesRead: null, 78 | }; 79 | }; 80 | -------------------------------------------------------------------------------- /src/utils/decode_uri_component_safe.ts: -------------------------------------------------------------------------------- 1 | export const decodeURIComponentSafe = (encodedURIComponent: string): string => { 2 | try { 3 | return decodeURIComponent(encodedURIComponent); 4 | } catch { 5 | return encodedURIComponent; 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /src/utils/get_basic.ts: -------------------------------------------------------------------------------- 1 | import type { URL } from 'node:url'; 2 | 3 | import { decodeURIComponentSafe } from './decode_uri_component_safe'; 4 | 5 | export const getBasicAuthorizationHeader = (url: URL): string => { 6 | const username = decodeURIComponentSafe(url.username); 7 | const password = decodeURIComponentSafe(url.password); 8 | const auth = `${username}:${password}`; 9 | 10 | if (username.includes(':')) { 11 | throw new Error('Username contains an invalid colon'); 12 | } 13 | 14 | return `Basic ${Buffer.from(auth).toString('base64')}`; 15 | }; 16 | -------------------------------------------------------------------------------- /src/utils/is_hop_by_hop_header.ts: -------------------------------------------------------------------------------- 1 | // As per HTTP specification, hop-by-hop headers should be consumed but the proxy, and not forwarded 2 | const hopByHopHeaders = [ 3 | 'connection', 4 | 'keep-alive', 5 | 'proxy-authenticate', 6 | 'proxy-authorization', 7 | 'te', 8 | 'trailer', 9 | 'transfer-encoding', 10 | 'upgrade', 11 | ]; 12 | 13 | export const isHopByHopHeader = (header: string): boolean => hopByHopHeaders.includes(header.toLowerCase()); 14 | -------------------------------------------------------------------------------- /src/utils/nodeify.ts: -------------------------------------------------------------------------------- 1 | // Replacement for Bluebird's Promise.nodeify() 2 | export const nodeify = async (promise: Promise, callback?: (error: Error | null, result?: T) => void): Promise => { 3 | if (typeof callback !== 'function') return promise; 4 | 5 | promise.then( 6 | (result) => callback(null, result), 7 | callback, 8 | ).catch((error) => { 9 | // Need to .catch because it doesn't crash the process on Node.js 14 10 | process.nextTick(() => { 11 | throw error; 12 | }); 13 | }); 14 | 15 | return promise; 16 | }; 17 | -------------------------------------------------------------------------------- /src/utils/normalize_url_port.ts: -------------------------------------------------------------------------------- 1 | import type { URL } from 'node:url'; 2 | 3 | // https://url.spec.whatwg.org/#default-port 4 | const mapping = { 5 | 'ftp:': 21, 6 | 'http:': 80, 7 | 'https:': 443, 8 | 'ws:': 80, 9 | 'wss:': 443, 10 | }; 11 | 12 | export const normalizeUrlPort = (url: URL): number => { 13 | if (url.port) { 14 | return Number(url.port); 15 | } 16 | 17 | if (url.protocol in mapping) { 18 | return mapping[url.protocol as keyof typeof mapping]; 19 | } 20 | 21 | throw new Error(`Unexpected protocol: ${url.protocol}`); 22 | }; 23 | -------------------------------------------------------------------------------- /src/utils/parse_authorization_header.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from 'node:buffer'; 2 | 3 | const splitAt = (string: string, index: number) => { 4 | return [ 5 | index === -1 ? '' : string.substring(0, index), 6 | index === -1 ? '' : string.substring(index + 1), 7 | ]; 8 | }; 9 | 10 | interface Authorization { 11 | type: string; 12 | data: string; 13 | username?: string; 14 | password?: string; 15 | } 16 | 17 | export const parseAuthorizationHeader = (header: string): Authorization | null => { 18 | if (header) { 19 | header = header.trim(); 20 | } 21 | 22 | if (!header) { 23 | return null; 24 | } 25 | 26 | const [type, data] = splitAt(header, header.indexOf(' ')); 27 | 28 | // https://datatracker.ietf.org/doc/html/rfc7617#page-3 29 | // Note that both scheme and parameter names are matched case- 30 | // insensitively. 31 | if (type.toLowerCase() !== 'basic') { 32 | return { type, data }; 33 | } 34 | 35 | const auth = Buffer.from(data, 'base64').toString(); 36 | 37 | // https://datatracker.ietf.org/doc/html/rfc7617#page-5 38 | // To receive authorization, the client 39 | // 40 | // 1. obtains the user-id and password from the user, 41 | // 42 | // 2. constructs the user-pass by concatenating the user-id, a single 43 | // colon (":") character, and the password, 44 | // 45 | // 3. encodes the user-pass into an octet sequence (see below for a 46 | // discussion of character encoding schemes), 47 | // 48 | // 4. and obtains the basic-credentials by encoding this octet sequence 49 | // using Base64 ([RFC4648], Section 4) into a sequence of US-ASCII 50 | // characters ([RFC0020]). 51 | 52 | // Note: 53 | // If there's a colon : missing, we imply that the user-pass string is just a username. 54 | // This is a non-spec behavior. At Apify there are clients that rely on this. 55 | // If you want this behavior changed, please open an issue. 56 | const [username, password] = auth.includes(':') ? splitAt(auth, auth.indexOf(':')) : [auth, '']; 57 | 58 | return { 59 | type, 60 | data, 61 | username, 62 | password, 63 | }; 64 | }; 65 | -------------------------------------------------------------------------------- /src/utils/redact_url.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'node:url'; 2 | 3 | export const redactUrl = (url: string | URL, passwordReplacement = ''): string => { 4 | if (typeof url !== 'object') { 5 | url = new URL(url); 6 | } 7 | 8 | if (url.password) { 9 | return url.href.replace(`:${url.password}`, `:${passwordReplacement}`); 10 | } 11 | 12 | return url.href; 13 | }; 14 | -------------------------------------------------------------------------------- /src/utils/valid_headers_only.ts: -------------------------------------------------------------------------------- 1 | import { validateHeaderName, validateHeaderValue } from 'node:http'; 2 | 3 | import { isHopByHopHeader } from './is_hop_by_hop_header'; 4 | 5 | /** 6 | * @see https://nodejs.org/api/http.html#http_message_rawheaders 7 | */ 8 | export const validHeadersOnly = (rawHeaders: string[]): string[] => { 9 | const result = []; 10 | 11 | let containsHost = false; 12 | 13 | for (let i = 0; i < rawHeaders.length; i += 2) { 14 | const name = rawHeaders[i]; 15 | const value = rawHeaders[i + 1]; 16 | 17 | try { 18 | validateHeaderName(name); 19 | validateHeaderValue(name, value); 20 | } catch { 21 | continue; 22 | } 23 | 24 | if (isHopByHopHeader(name)) { 25 | continue; 26 | } 27 | 28 | if (name.toLowerCase() === 'host') { 29 | if (containsHost) { 30 | continue; 31 | } 32 | 33 | containsHost = true; 34 | } 35 | 36 | result.push(name, value); 37 | } 38 | 39 | return result; 40 | }; 41 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "it": false, 4 | "describe": false, 5 | "xit": false, 6 | "before": false, 7 | "after": false, 8 | "beforeAll": false, 9 | "afterAll": false, 10 | "beforeEach": false, 11 | "afterEach": false, 12 | "expect": false, 13 | "test": false, 14 | "jest": false 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/Dockerfile: -------------------------------------------------------------------------------- 1 | # You can use this Dockerfile to run the tests on Linux, 2 | # since the sockets behave a little differently there than on Mac 3 | # 4 | # Usage: 5 | # > docker build . 6 | # > docker run 7 | 8 | FROM node:10 9 | 10 | COPY .. /home/node/ 11 | 12 | RUN npm --quiet set progress=false \ 13 | && npm install --only=prod --no-optional \ 14 | && echo "Installed NPM packages:" \ 15 | && npm list || true \ 16 | && echo "Node.js version:" \ 17 | && node --version \ 18 | && echo "NPM version:" \ 19 | && npm --version 20 | 21 | CMD cd /home/node && npm test 22 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | 2 | To run the tests, you need to add the following line to your `/etc/hosts`: 3 | 4 | ``` 5 | # Used by proxy-chain NPM package tests 6 | 127.0.0.1 localhost 7 | 127.0.0.1 localhost-test 8 | ``` 9 | 10 | The `localhost` entry is for avoiding dual-stack issues, e.g. when the test server listens at ::1 11 | (results of getaddrinfo have specifed order) and the client attempts to connect to 127.0.0.1 . 12 | 13 | The `localhost-test` entry is a workaround to PhantomJS' behavior where it skips proxy servers for 14 | localhost addresses. 15 | -------------------------------------------------------------------------------- /test/anonymize_proxy.js: -------------------------------------------------------------------------------- 1 | const _ = require('underscore'); 2 | const util = require('util'); 3 | const { expect, assert } = require('chai'); 4 | const proxy = require('proxy'); 5 | const http = require('http'); 6 | const portastic = require('portastic'); 7 | const basicAuthParser = require('basic-auth-parser'); 8 | const request = require('request'); 9 | const express = require('express'); 10 | 11 | const { anonymizeProxy, closeAnonymizedProxy, listenConnectAnonymizedProxy } = require('../src/index'); 12 | const { expectThrowsAsync } = require('./utils/throws_async'); 13 | 14 | let expressServer; 15 | let proxyServer; 16 | let proxyPort; 17 | let testServerPort; 18 | const proxyAuth = { scheme: 'Basic', username: 'username', password: 'password' }; 19 | let wasProxyCalled = false; 20 | 21 | const serverListen = (server, port) => new Promise((resolve, reject) => { 22 | server.once('error', reject); 23 | 24 | server.listen(port, () => { 25 | server.off('error', reject); 26 | 27 | resolve(server.address().port); 28 | }); 29 | }); 30 | 31 | // Setup local proxy server and web server for the tests 32 | before(() => { 33 | // Find free port for the proxy 34 | let freePorts; 35 | return portastic.find({ min: 50000, max: 50100 }) 36 | .then((result) => { 37 | freePorts = result; 38 | return new Promise((resolve, reject) => { 39 | const httpServer = http.createServer(); 40 | 41 | // Setup proxy authorization 42 | httpServer.authenticate = function (req, fn) { 43 | // parse the "Proxy-Authorization" header 44 | const auth = req.headers['proxy-authorization']; 45 | if (!auth) { 46 | // optimization: don't invoke the child process if no 47 | // "Proxy-Authorization" header was given 48 | // console.log('not Proxy-Authorization'); 49 | return fn(null, false); 50 | } 51 | const parsed = basicAuthParser(auth); 52 | const isEqual = _.isEqual(parsed, proxyAuth); 53 | // console.log('Parsed "Proxy-Authorization": parsed: %j expected: %j : %s', parsed, proxyAuth, isEqual); 54 | if (isEqual) wasProxyCalled = true; 55 | fn(null, isEqual); 56 | }; 57 | 58 | httpServer.on('error', reject); 59 | 60 | proxyServer = proxy(httpServer); 61 | proxyServer.listen(freePorts[0], () => { 62 | proxyPort = proxyServer.address().port; 63 | resolve(); 64 | }); 65 | }); 66 | }) 67 | .then(() => { 68 | const app = express(); 69 | 70 | app.get('/', (req, res) => res.send('Hello World!')); 71 | 72 | // eslint-disable-next-line prefer-destructuring 73 | testServerPort = freePorts[1]; 74 | return new Promise((resolve, reject) => { 75 | expressServer = app.listen(testServerPort, () => { 76 | resolve(); 77 | }); 78 | }); 79 | }); 80 | }); 81 | 82 | after(function () { 83 | this.timeout(5 * 1000); 84 | 85 | expressServer.close(); 86 | 87 | if (proxyServer) return util.promisify(proxyServer.close).bind(proxyServer)(); 88 | }); 89 | 90 | const requestPromised = (opts) => { 91 | // console.log('requestPromised'); 92 | // console.dir(opts); 93 | return new Promise((resolve, reject) => { 94 | request(opts, (error, response, body) => { 95 | if (error) return reject(error); 96 | if (response.statusCode !== 200) { 97 | return reject(new Error(`Received invalid response code: ${response.statusCode}`)); 98 | } 99 | if (opts.expectBodyContainsText) expect(body).to.contain(opts.expectBodyContainsText); 100 | resolve(); 101 | }); 102 | }); 103 | }; 104 | 105 | describe('utils.anonymizeProxy', function () { 106 | // Need larger timeout for Travis CI 107 | this.timeout(5 * 1000); 108 | it('throws for invalid args', () => { 109 | expectThrowsAsync(async () => { await anonymizeProxy(null); }); 110 | expectThrowsAsync(async () => { await anonymizeProxy(); }); 111 | expectThrowsAsync(async () => { await anonymizeProxy({}); }); 112 | 113 | expectThrowsAsync(async () => { await closeAnonymizedProxy({}); }); 114 | expectThrowsAsync(async () => { await closeAnonymizedProxy(); }); 115 | expectThrowsAsync(async () => { await closeAnonymizedProxy(null); }); 116 | }); 117 | 118 | it('throws for unsupported https: protocol', () => { 119 | expectThrowsAsync(async () => { await anonymizeProxy('https://whatever.com'); }); 120 | expectThrowsAsync(async () => { await anonymizeProxy({ url: 'https://whatever.com' }); }); 121 | }); 122 | 123 | it('throws for invalid ports', () => { 124 | expectThrowsAsync(async () => { await anonymizeProxy({ url: 'http://whatever.com', port: -16 }); }); 125 | expectThrowsAsync(async () => { 126 | await anonymizeProxy({ 127 | url: 'http://whatever.com', 128 | port: 4324324324, 129 | }); 130 | }); 131 | }); 132 | 133 | it('throws for invalid URLs', () => { 134 | expectThrowsAsync(async () => { await anonymizeProxy('://whatever.com'); }); 135 | expectThrowsAsync(async () => { await anonymizeProxy('https://whatever.com'); }); 136 | expectThrowsAsync(async () => { await anonymizeProxy({ url: '://whatever.com' }); }); 137 | expectThrowsAsync(async () => { await anonymizeProxy({ url: 'https://whatever.com' }); }); 138 | }); 139 | 140 | it('keeps already anonymous proxies (both with callbacks and promises)', () => { 141 | return Promise.resolve() 142 | .then(() => { 143 | return anonymizeProxy('http://whatever:4567'); 144 | }) 145 | .then((anonymousProxyUrl) => { 146 | expect(anonymousProxyUrl).to.eql('http://whatever:4567'); 147 | }) 148 | .then(() => { 149 | return new Promise((resolve, reject) => { 150 | anonymizeProxy('http://whatever:4567', (err, result) => { 151 | if (err) return reject(err); 152 | resolve(result); 153 | }); 154 | }); 155 | }) 156 | .then((anonymousProxyUrl) => { 157 | expect(anonymousProxyUrl).to.eql('http://whatever:4567'); 158 | }); 159 | }); 160 | 161 | it('anonymizes authenticated upstream proxy (both with callbacks and promises)', () => { 162 | let proxyUrl1; 163 | let proxyUrl2; 164 | return Promise.resolve() 165 | .then(() => { 166 | return Promise.all([ 167 | anonymizeProxy(`http://${proxyAuth.username}:${proxyAuth.password}@127.0.0.1:${proxyPort}`), 168 | new Promise((resolve, reject) => { 169 | anonymizeProxy(`http://${proxyAuth.username}:${proxyAuth.password}@127.0.0.1:${proxyPort}`, (err, result) => { 170 | if (err) return reject(err); 171 | resolve(result); 172 | }); 173 | }), 174 | ]); 175 | }) 176 | .then((results) => { 177 | [proxyUrl1, proxyUrl2] = results; 178 | expect(proxyUrl1).to.not.contain(`${proxyPort}`); 179 | expect(proxyUrl2).to.not.contain(`${proxyPort}`); 180 | expect(proxyUrl1).to.not.equal(proxyUrl2); 181 | 182 | // Test call through proxy 1 183 | wasProxyCalled = false; 184 | return requestPromised({ 185 | uri: `http://localhost:${testServerPort}`, 186 | proxy: proxyUrl1, 187 | expectBodyContainsText: 'Hello World!', 188 | }); 189 | }) 190 | .then(() => { 191 | expect(wasProxyCalled).to.equal(true); 192 | }) 193 | .then(() => { 194 | // Test call through proxy 2 195 | wasProxyCalled = false; 196 | return requestPromised({ 197 | uri: `http://localhost:${testServerPort}`, 198 | proxy: proxyUrl2, 199 | expectBodyContainsText: 'Hello World!', 200 | }); 201 | }) 202 | .then(() => { 203 | expect(wasProxyCalled).to.equal(true); 204 | }) 205 | .then(() => { 206 | // Test again call through proxy 1 207 | wasProxyCalled = false; 208 | return requestPromised({ 209 | uri: `http://localhost:${testServerPort}`, 210 | proxy: proxyUrl1, 211 | expectBodyContainsText: 'Hello World!', 212 | }); 213 | }) 214 | .then(() => { 215 | expect(wasProxyCalled).to.equal(true); 216 | }) 217 | .then(() => closeAnonymizedProxy(proxyUrl1, true)) 218 | .then((closed) => { 219 | expect(closed).to.eql(true); 220 | 221 | // Test proxy is really closed 222 | return requestPromised({ 223 | uri: proxyUrl1, 224 | }) 225 | .then(() => { 226 | assert.fail(); 227 | }) 228 | .catch((err) => { 229 | expect(err.message).to.contain('ECONNREFUSED'); 230 | }); 231 | }) 232 | .then(() => { 233 | // Test callback-style 234 | return new Promise((resolve, reject) => { 235 | closeAnonymizedProxy(proxyUrl2, true, (err, closed) => { 236 | if (err) return reject(err); 237 | resolve(closed); 238 | }); 239 | }); 240 | }) 241 | .then((closed) => { 242 | expect(closed).to.eql(true); 243 | 244 | // Test the second-time call to close 245 | return closeAnonymizedProxy(proxyUrl1, true); 246 | }) 247 | .then((closed) => { 248 | expect(closed).to.eql(false); 249 | 250 | // Test callback-style 251 | return new Promise((resolve, reject) => { 252 | closeAnonymizedProxy(proxyUrl2, false, (err, closed2) => { 253 | if (err) return reject(err); 254 | resolve(closed2); 255 | }); 256 | }); 257 | }) 258 | .then((closed) => { 259 | expect(closed).to.eql(false); 260 | }); 261 | }); 262 | 263 | it('handles many concurrent calls without port collision', () => { 264 | const N = 20; 265 | let proxyUrls; 266 | 267 | return Promise.resolve() 268 | .then(() => { 269 | const promises = []; 270 | for (let i = 0; i < N; i++) { 271 | promises.push(anonymizeProxy(`http://${proxyAuth.username}:${proxyAuth.password}@127.0.0.1:${proxyPort}`)); 272 | } 273 | 274 | return Promise.all(promises); 275 | }) 276 | .then((results) => { 277 | const promises = []; 278 | proxyUrls = results; 279 | for (let i = 0; i < N; i++) { 280 | expect(proxyUrls[i]).to.not.contain(`${proxyPort}`); 281 | 282 | // Test call through proxy 283 | promises.push(requestPromised({ 284 | uri: `http://localhost:${testServerPort}`, 285 | proxy: proxyUrls[i], 286 | expectBodyContainsText: 'Hello World!', 287 | })); 288 | } 289 | 290 | return Promise.all(promises); 291 | }) 292 | .then(() => { 293 | expect(wasProxyCalled).to.equal(true); 294 | const promises = []; 295 | 296 | for (let i = 0; i < N; i++) { 297 | promises.push(closeAnonymizedProxy(proxyUrls[i], true)); 298 | } 299 | 300 | return Promise.all(promises); 301 | }) 302 | .then((results) => { 303 | for (let i = 0; i < N; i++) { 304 | expect(results[i]).to.eql(true); 305 | } 306 | }); 307 | }); 308 | 309 | it('handles HTTP CONNECT request properly', function () { 310 | this.timeout(50 * 1000); 311 | 312 | const host = `localhost:${testServerPort}`; 313 | let onconnectArgs; 314 | function onconnect(message, socket) { 315 | onconnectArgs = message; 316 | socket.write('HTTP/1.1 401 UNAUTHORIZED\r\n\r\n'); 317 | socket.end(); 318 | socket.destroy(); 319 | } 320 | 321 | const localProxy = http.createServer(); 322 | localProxy.on('connect', onconnect); 323 | 324 | let proxyUrl; 325 | 326 | return serverListen(localProxy, 0) 327 | .then(() => anonymizeProxy(`http://${proxyAuth.username}:${proxyAuth.password}@127.0.0.1:${localProxy.address().port}`)) 328 | .then((url) => { 329 | proxyUrl = url; 330 | 331 | return requestPromised({ 332 | uri: `https://${host}`, 333 | proxy: proxyUrl, 334 | }); 335 | }) 336 | .then(() => { 337 | expect(false).to.equal(true); 338 | }, () => { 339 | expect(onconnectArgs.headers.host).to.equal(host); 340 | expect(onconnectArgs.url).to.equal(host); 341 | }) 342 | .finally(() => closeAnonymizedProxy(proxyUrl, true)) 343 | .finally(() => localProxy.close()); 344 | }); 345 | 346 | it('handles HTTP CONNECT callback properly', function () { 347 | this.timeout(50 * 1000); 348 | 349 | const host = `localhost:${testServerPort}`; 350 | let rawHeadersRetrieved; 351 | function onconnect(message, socket) { 352 | socket.write('HTTP/1.1 200 OK\r\nfoo: bar\r\n\r\n'); 353 | socket.end(); 354 | socket.destroy(); 355 | } 356 | 357 | let proxyUrl; 358 | 359 | const localProxy = http.createServer(); 360 | localProxy.on('connect', onconnect); 361 | 362 | return serverListen(localProxy, 0) 363 | .then(() => anonymizeProxy(`http://${proxyAuth.username}:${proxyAuth.password}@127.0.0.1:${localProxy.address().port}`)) 364 | .then((url) => { 365 | proxyUrl = url; 366 | 367 | listenConnectAnonymizedProxy(proxyUrl, ({ response, socket, head }) => { 368 | rawHeadersRetrieved = response.rawHeaders; 369 | }); 370 | return requestPromised({ 371 | uri: `https://${host}`, 372 | proxy: proxyUrl, 373 | }) 374 | .catch(() => {}); 375 | }) 376 | .then(() => { 377 | expect(rawHeadersRetrieved).to.eql(['foo', 'bar']); 378 | }) 379 | .finally(() => closeAnonymizedProxy(proxyUrl, true)) 380 | .finally(() => localProxy.close()); 381 | }); 382 | 383 | it('fails with invalid upstream proxy credentials', () => { 384 | let anonymousProxyUrl; 385 | return Promise.resolve() 386 | .then(() => { 387 | return anonymizeProxy(`http://username:bad-password@127.0.0.1:${proxyPort}`); 388 | }) 389 | .then((result) => { 390 | anonymousProxyUrl = result; 391 | expect(anonymousProxyUrl).to.not.contain(`${proxyPort}`); 392 | wasProxyCalled = false; 393 | return requestPromised({ 394 | uri: 'http://whatever', 395 | proxy: anonymousProxyUrl, 396 | }); 397 | }) 398 | .then(() => { 399 | assert.fail(); 400 | }) 401 | .catch((err) => { 402 | expect(err.message).to.contains('Received invalid response code: 597'); // Gateway error 403 | expect(wasProxyCalled).to.equal(false); 404 | }) 405 | .then(() => closeAnonymizedProxy(anonymousProxyUrl, true)) 406 | .then((closed) => { 407 | expect(closed).to.eql(true); 408 | }); 409 | }); 410 | }); 411 | -------------------------------------------------------------------------------- /test/anonymize_proxy_no_password.js: -------------------------------------------------------------------------------- 1 | const _ = require('underscore'); 2 | const { expect, assert } = require('chai'); 3 | const proxy = require('proxy'); 4 | const http = require('http'); 5 | const util = require('util'); 6 | const portastic = require('portastic'); 7 | const basicAuthParser = require('basic-auth-parser'); 8 | const request = require('request'); 9 | const express = require('express'); 10 | 11 | const { anonymizeProxy, closeAnonymizedProxy } = require('../src/index'); 12 | 13 | let expressServer; 14 | let proxyServer; 15 | let proxyPort; 16 | let testServerPort; 17 | const proxyAuth = { scheme: 'Basic', username: 'username', password: '' }; 18 | let wasProxyCalled = false; 19 | 20 | // Setup local proxy server and web server for the tests 21 | before(() => { 22 | // Find free port for the proxy 23 | let freePorts; 24 | return portastic.find({ min: 50000, max: 50100 }) 25 | .then((result) => { 26 | freePorts = result; 27 | return new Promise((resolve, reject) => { 28 | const httpServer = http.createServer(); 29 | 30 | // Setup proxy authorization 31 | httpServer.authenticate = function (req, fn) { 32 | // parse the "Proxy-Authorization" header 33 | const auth = req.headers['proxy-authorization']; 34 | if (!auth) { 35 | // optimization: don't invoke the child process if no 36 | // "Proxy-Authorization" header was given 37 | // console.log('not Proxy-Authorization'); 38 | return fn(null, false); 39 | } 40 | const parsed = basicAuthParser(auth); 41 | const isEqual = _.isEqual(parsed, proxyAuth); 42 | // console.log('Parsed "Proxy-Authorization": parsed: %j expected: %j : %s', parsed, proxyAuth, isEqual); 43 | if (isEqual) wasProxyCalled = true; 44 | fn(null, isEqual); 45 | }; 46 | 47 | httpServer.on('error', reject); 48 | 49 | proxyServer = proxy(httpServer); 50 | proxyServer.listen(freePorts[0], () => { 51 | proxyPort = proxyServer.address().port; 52 | resolve(); 53 | }); 54 | }); 55 | }) 56 | .then(() => { 57 | const app = express(); 58 | 59 | app.get('/', (req, res) => res.send('Hello World!')); 60 | 61 | testServerPort = freePorts[1]; 62 | return new Promise((resolve, reject) => { 63 | expressServer = app.listen(testServerPort, () => { 64 | resolve(); 65 | }); 66 | }); 67 | }); 68 | }); 69 | 70 | after(function () { 71 | this.timeout(5 * 1000); 72 | expressServer.close(); 73 | 74 | if (proxyServer) return util.promisify(proxyServer.close).bind(proxyServer)(); 75 | }); 76 | 77 | const requestPromised = (opts) => { 78 | // console.log('requestPromised'); 79 | // console.dir(opts); 80 | return new Promise((resolve, reject) => { 81 | request(opts, (error, response, body) => { 82 | if (error) return reject(error); 83 | if (response.statusCode !== 200) { 84 | return reject(new Error(`Received invalid response code: ${response.statusCode}`)); 85 | } 86 | if (opts.expectBodyContainsText) expect(body).to.contain(opts.expectBodyContainsText); 87 | resolve(); 88 | }); 89 | }); 90 | }; 91 | 92 | 93 | describe('utils.anonymizeProxyNoPassword', function () { 94 | // Need larger timeout for Travis CI 95 | this.timeout(5 * 1000); 96 | it('anonymizes authenticated with no password upstream proxy (both with callbacks and promises)', () => { 97 | let proxyUrl1; 98 | let proxyUrl2; 99 | return Promise.resolve() 100 | .then(() => { 101 | return Promise.all([ 102 | anonymizeProxy(`http://${proxyAuth.username}:${proxyAuth.password}@127.0.0.1:${proxyPort}`), 103 | new Promise((resolve, reject) => { 104 | anonymizeProxy(`http://${proxyAuth.username}:${proxyAuth.password}@127.0.0.1:${proxyPort}`, 105 | (err, result) => { 106 | if (err) return reject(err); 107 | resolve(result); 108 | }); 109 | }), 110 | ]); 111 | }) 112 | .then((results) => { 113 | proxyUrl1 = results[0]; 114 | proxyUrl2 = results[1]; 115 | expect(proxyUrl1).to.not.contain(`${proxyPort}`); 116 | expect(proxyUrl2).to.not.contain(`${proxyPort}`); 117 | expect(proxyUrl1).to.not.equal(proxyUrl2); 118 | 119 | // Test call through proxy 1 120 | wasProxyCalled = false; 121 | return requestPromised({ 122 | uri: `http://localhost:${testServerPort}`, 123 | proxy: proxyUrl1, 124 | expectBodyContainsText: 'Hello World!', 125 | }); 126 | }) 127 | .then(() => { 128 | expect(wasProxyCalled).to.equal(true); 129 | }) 130 | .then(() => { 131 | // Test call through proxy 2 132 | wasProxyCalled = false; 133 | return requestPromised({ 134 | uri: `http://localhost:${testServerPort}`, 135 | proxy: proxyUrl2, 136 | expectBodyContainsText: 'Hello World!', 137 | }); 138 | }) 139 | .then(() => { 140 | expect(wasProxyCalled).to.equal(true); 141 | }) 142 | .then(() => { 143 | // Test again call through proxy 1 144 | wasProxyCalled = false; 145 | return requestPromised({ 146 | uri: `http://localhost:${testServerPort}`, 147 | proxy: proxyUrl1, 148 | expectBodyContainsText: 'Hello World!', 149 | }); 150 | }) 151 | .then(() => { 152 | expect(wasProxyCalled).to.equal(true); 153 | }) 154 | .then(() => { 155 | return closeAnonymizedProxy(proxyUrl1, true); 156 | }) 157 | .then((closed) => { 158 | expect(closed).to.eql(true); 159 | 160 | // Test proxy is really closed 161 | return requestPromised({ 162 | uri: proxyUrl1, 163 | }) 164 | .then(() => { 165 | assert.fail(); 166 | }) 167 | .catch((err) => { 168 | expect(err.message).to.contain('ECONNREFUSED'); 169 | }); 170 | }) 171 | .then(() => { 172 | // Test callback-style 173 | return new Promise((resolve, reject) => { 174 | closeAnonymizedProxy(proxyUrl2, true, (err, closed) => { 175 | if (err) return reject(err); 176 | resolve(closed); 177 | }); 178 | }); 179 | }) 180 | .then((closed) => { 181 | expect(closed).to.eql(true); 182 | 183 | // Test the second-time call to close 184 | return closeAnonymizedProxy(proxyUrl1, true); 185 | }) 186 | .then((closed) => { 187 | expect(closed).to.eql(false); 188 | 189 | // Test callback-style 190 | return new Promise((resolve, reject) => { 191 | closeAnonymizedProxy(proxyUrl2, false, (err, closed) => { 192 | if (err) return reject(err); 193 | resolve(closed); 194 | }); 195 | }); 196 | }) 197 | .then((closed) => { 198 | expect(closed).to.eql(false); 199 | }); 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /test/ee-memory-leak.js: -------------------------------------------------------------------------------- 1 | const net = require('net'); 2 | const http = require('http'); 3 | const { assert } = require('chai'); 4 | const ProxyChain = require('../src/index'); 5 | 6 | describe('ProxyChain server', () => { 7 | let proxyServer; 8 | let server; 9 | let port; 10 | 11 | before(() => { 12 | proxyServer = new ProxyChain.Server(); 13 | 14 | server = http.createServer((_request, response) => { 15 | response.end('Hello, world!'); 16 | }).listen(0); 17 | 18 | port = server.address().port; 19 | }); 20 | 21 | after(() => { 22 | proxyServer.close(); 23 | server.close(); 24 | }); 25 | 26 | it('does not leak events', (done) => { 27 | let socket; 28 | let registeredCount; 29 | proxyServer.server.prependOnceListener('request', (request) => { 30 | socket = request.socket; 31 | registeredCount = socket.listenerCount('error'); 32 | }); 33 | 34 | const callback = () => { 35 | assert.equal(socket.listenerCount('error'), registeredCount); 36 | done(); 37 | }; 38 | 39 | proxyServer.listen(async () => { 40 | const proxyServerPort = proxyServer.server.address().port; 41 | 42 | const requestCount = 20; 43 | 44 | const client = net.connect({ 45 | host: 'localhost', 46 | port: proxyServerPort, 47 | }); 48 | 49 | client.setTimeout(100); 50 | 51 | client.on('timeout', () => { 52 | client.destroy(); 53 | callback(); 54 | }); 55 | 56 | for (let i = 0; i < requestCount; i++) { 57 | client.write(`GET http://localhost:${port} HTTP/1.1\r\nhost: localhost:${port}\r\nconnection: keep-alive\r\n\r\n`); 58 | } 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /test/phantom_get.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // Only run this code in the PhantomJS environment 4 | if (typeof(phantom)==='object') { 5 | var page = require('webpage').create(); 6 | var system = require('system'); 7 | var settings = { 8 | resourceTimeout: 10 * 1000, 9 | }; 10 | 11 | if (system.args.length < 2) { 12 | console.log('Opens a web page and prints its content'); 13 | console.log('Usage: phantomjs phantomjs_get.js URL [--verbose]'); 14 | phantom.exit(1); 15 | } else { 16 | var url = system.args[1]; 17 | var verbose = system.args[2]; 18 | 19 | if (verbose) { 20 | page.onError = function (msg, trace) { 21 | console.log('ERROR: ' + msg); 22 | console.log('TRACE: ' + trace); 23 | }; 24 | page.onResourceError = function (resourceError) { 25 | console.log('RESOURCE ERROR: ' + JSON.stringify(resourceError)); 26 | }; 27 | page.onResourceTimeout = function (response) { 28 | console.log('RESOURCE TIMEOUT: ' + JSON.stringify(response)); 29 | }; 30 | } 31 | 32 | page.open(url, settings, function (status) { 33 | if (status !== 'success') { 34 | console.log('Unable to load ' + url); 35 | phantom.exit(1); 36 | } else { 37 | console.log(page.content); 38 | phantom.exit(0); 39 | } 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/socks.js: -------------------------------------------------------------------------------- 1 | const portastic = require('portastic'); 2 | const socksv5 = require('socksv5'); 3 | const { gotScraping } = require('got-scraping'); 4 | const { expect } = require('chai'); 5 | const ProxyChain = require('../src/index'); 6 | 7 | describe('SOCKS protocol', () => { 8 | let socksServer; 9 | let proxyServer; 10 | let anonymizeProxyUrl; 11 | 12 | afterEach(() => { 13 | if (socksServer) socksServer.close(); 14 | if (proxyServer) proxyServer.close(); 15 | if (anonymizeProxyUrl) ProxyChain.closeAnonymizedProxy(anonymizeProxyUrl, true); 16 | }); 17 | 18 | it('works without auth', (done) => { 19 | portastic.find({ min: 50000, max: 50250 }).then((ports) => { 20 | const [socksPort, proxyPort] = ports; 21 | socksServer = socksv5.createServer((info, accept) => { 22 | accept(); 23 | }); 24 | socksServer.listen(socksPort, 'localhost'); 25 | socksServer.useAuth(socksv5.auth.None()); 26 | 27 | proxyServer = new ProxyChain.Server({ 28 | port: proxyPort, 29 | prepareRequestFunction() { 30 | return { 31 | upstreamProxyUrl: `socks://localhost:${socksPort}`, 32 | }; 33 | }, 34 | }); 35 | proxyServer.listen(); 36 | 37 | gotScraping.get({ url: 'https://example.com', proxyUrl: `http://127.0.0.1:${proxyPort}` }) 38 | .then((response) => { 39 | expect(response.body).to.contain('Example Domain'); 40 | done(); 41 | }) 42 | .catch(done); 43 | }); 44 | }).timeout(5 * 1000); 45 | 46 | it('work with auth', (done) => { 47 | portastic.find({ min: 50250, max: 50500 }).then((ports) => { 48 | const [socksPort, proxyPort] = ports; 49 | socksServer = socksv5.createServer((info, accept) => { 50 | accept(); 51 | }); 52 | socksServer.listen(socksPort, 'localhost'); 53 | socksServer.useAuth(socksv5.auth.UserPassword((user, password, cb) => { 54 | cb(user === 'proxy-ch@in' && password === 'rules!'); 55 | })); 56 | 57 | proxyServer = new ProxyChain.Server({ 58 | port: proxyPort, 59 | prepareRequestFunction() { 60 | return { 61 | upstreamProxyUrl: `socks://proxy-ch@in:rules!@localhost:${socksPort}`, 62 | }; 63 | }, 64 | }); 65 | proxyServer.listen(); 66 | 67 | gotScraping.get({ url: 'https://example.com', proxyUrl: `http://127.0.0.1:${proxyPort}` }) 68 | .then((response) => { 69 | expect(response.body).to.contain('Example Domain'); 70 | done(); 71 | }) 72 | .catch(done); 73 | }); 74 | }).timeout(5 * 1000); 75 | 76 | it('works with anonymizeProxy', (done) => { 77 | portastic.find({ min: 50500, max: 50750 }).then((ports) => { 78 | const [socksPort, proxyPort] = ports; 79 | socksServer = socksv5.createServer((info, accept) => { 80 | accept(); 81 | }); 82 | socksServer.listen(socksPort, 'localhost'); 83 | socksServer.useAuth(socksv5.auth.UserPassword((user, password, cb) => { 84 | cb(user === 'proxy-ch@in' && password === 'rules!'); 85 | })); 86 | 87 | ProxyChain.anonymizeProxy({ port: proxyPort, url: `socks://proxy-ch@in:rules!@localhost:${socksPort}` }).then((anonymizedProxyUrl) => { 88 | anonymizeProxyUrl = anonymizedProxyUrl; 89 | gotScraping.get({ url: 'https://example.com', proxyUrl: anonymizedProxyUrl }) 90 | .then((response) => { 91 | expect(response.body).to.contain('Example Domain'); 92 | done(); 93 | }) 94 | .catch(done); 95 | }); 96 | }); 97 | }).timeout(5 * 1000); 98 | }); 99 | -------------------------------------------------------------------------------- /test/ssl.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDtTCCAp2gAwIBAgIJAOyaEf+jBkG4MA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV 3 | BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX 4 | aWRnaXRzIFB0eSBMdGQwHhcNMTcxMTEwMjAzNDM0WhcNMTgxMTEwMjAzNDM0WjBF 5 | MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 6 | ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB 7 | CgKCAQEAveAoPujCQ7RN2R09/Gp8cby6DyVOyob9VdiSJp8tTjWL0YmuGMdDa84n 8 | BbUbig8z2J5zvnce8/kwIGEIpe9Aho4pNHe9+q+BaLNWdFdazDO2rVjIuDNvylqB 9 | UZ3MeVY7uhVPIc7i4I8nh48dLIwCoo6bZuAKWjGNbOZ34iuvocixeLLjD9FPrfyS 10 | miFNvYYBIIE1cuG6v4c/6D58TNkon2dIWk4WdT8exRggSSkcn0gkfj0V7c4pbJsh 11 | xe2EihLEvT5CIL2oucQw0Nq1kzRBl9nIglrd7DO9CAYPlx3Kx3WoHG4MdibfbHbI 12 | WcaWbQcNTKOXMQa5bEsijzEd3uzxrQIDAQABo4GnMIGkMB0GA1UdDgQWBBR/xkww 13 | 83cpqsT61bGnym/mFdbn9TB1BgNVHSMEbjBsgBR/xkww83cpqsT61bGnym/mFdbn 14 | 9aFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV 15 | BAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAOyaEf+jBkG4MAwGA1UdEwQF 16 | MAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAJvr1vJO373jCTztVWqs1DxVUpC8TMAO 17 | zrv4Ry+1xcxowDkdyTNXhwfqshbrTEmfhl92zjNy4ZrYN/KN8kM+jg/fHbw5KNSd 18 | uNH2a74BuXVQR/fscFPsqmIWlsyrSCKpRUi0dLKo67ZrBcnUMYwBnxdQxu0hoB81 19 | B5ZDLptogoc3YN8+XmjqghKEx22hC1+RalQ4pI3n7ru73NLukLJb2c4kjK9AsZq3 20 | 44Q5+RajPtFha+mTlRyh9ZCMWgjzqESfvGKHoIq2gcLGWN2FuqKS9SIU8TfdUoh4 21 | N7ABI4y4lktKuq/5AcHZXuXwLiuCG3rOGeb6zgUV0jXb79C0unDWbTs= 22 | -----END CERTIFICATE----- 23 | -------------------------------------------------------------------------------- /test/ssl.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAveAoPujCQ7RN2R09/Gp8cby6DyVOyob9VdiSJp8tTjWL0Ymu 3 | GMdDa84nBbUbig8z2J5zvnce8/kwIGEIpe9Aho4pNHe9+q+BaLNWdFdazDO2rVjI 4 | uDNvylqBUZ3MeVY7uhVPIc7i4I8nh48dLIwCoo6bZuAKWjGNbOZ34iuvocixeLLj 5 | D9FPrfySmiFNvYYBIIE1cuG6v4c/6D58TNkon2dIWk4WdT8exRggSSkcn0gkfj0V 6 | 7c4pbJshxe2EihLEvT5CIL2oucQw0Nq1kzRBl9nIglrd7DO9CAYPlx3Kx3WoHG4M 7 | dibfbHbIWcaWbQcNTKOXMQa5bEsijzEd3uzxrQIDAQABAoIBAQCp2ZgG1m3Y5LRy 8 | 0I6/en5BvAJwQ/5cey6pmWb7t45ulMWzNkcPkUiFak9L8rtk376QOwXszmBY/IMJ 9 | o+N5lDETbJ39elPuqQrJHwvqXK4zVttF69L5u8F3sUhXOyJLNFGPXzp/UrNvD3/b 10 | 6rC9Ra2hvpHTD/0Su5r4XJ3HKy8cN4ErQprmEJhKDYrP/Lp2uKDPwoxXiYcPOPKC 11 | CbgPLRrK+40GbCXtZVQgX0+nrJ/0syryNaA9wb1wHXfdWLyhvM2BGHz9gXtv2z1L 12 | 3VvvbKpO2pygnLgUjLeJbk0/UI5Orz8MEAzGiq4wwiGqQZaGx/1C/WBlvnqnwuA5 13 | 6vQQC7Q9AoGBAOoQZbir+W+ihkwIkTGb6pHnaPTAuslPSfR6RJbxGlv78wJ260KE 14 | foRKVdb06gQWwZwHniKA0GZeKBFNxSSdkyt8yZY84/w9KLFitX5HaBFoggroOWx+ 15 | JCMuPDDnnlHm9REUzR2Zsl3KedNeV92JUDjx+ObUbexeK8wd0mLtqcO7AoGBAM+r 16 | mkJ4OrpTkPqVxkOim+LjMe/7EqX14QkPQrg3KzZmhyi7THOB5JMH+0/Bygrs1VP5 17 | OSEzWbPnjQ0GWoFEIw/iJbhvikhJPi4PXMrXIhreXcX7GezGp3nKHNf5vNpKAk4Z 18 | fPlCmHEBtcBHPygDPEODkTPz7B74QX6NX4ZrWCW3AoGAMg7Psm8VKYrYreonIzT1 19 | Nb8H81BEokkSx/ZeNOnbeVCo6B4GsnMjm6dKNG6snbNANN5sM3TZHQuGBi1bvDj3 20 | AJXvhvH+0DNEQKubpSYgW5i+NxbzMQDJObzpoovmkB2Uy9JnC62TN/vVkh7bK8Xy 21 | Ijudv8Auwh5hv4WhOQcbB4ECgYAqrG2PeRtATIm/JGXQYiq8TclmMeacGdF7RhqE 22 | tjl3/UuK0CoeljN9DyfSNNUqt44CqnTV4LJvKIawhXy1kWXPDr6HjswQnJRdbKS5 23 | vclxUf5c/4NNR2kEusaAjv4CsTCWEeC/a7LdjedmMn3E4B1TFkcRMO91UbhLpAtc 24 | GNTNMwKBgH954dHwNWbAXGJlqvP75MIuPdFNbi0TVKR8V9PbFg9eOVvWaeGGUr4I 25 | yUoDPTndfogpiT/PuXBy4IQ+BYNza0fTVcJzTD5vOgoeRYUDdL5SYAlnIhEVhw2U 26 | frBtb6JYt7jgP7HXyLG75+p+PVujxt20smxUKyLCfIqTNXeyIosW 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /test/tcp_tunnel.js: -------------------------------------------------------------------------------- 1 | const net = require('net'); 2 | const { expect, assert } = require('chai'); 3 | const http = require('http'); 4 | const proxy = require('proxy'); 5 | 6 | const { createTunnel, closeTunnel } = require('../src/index'); 7 | const { expectThrowsAsync } = require('./utils/throws_async'); 8 | 9 | const destroySocket = (socket) => new Promise((resolve, reject) => { 10 | if (!socket || socket.destroyed) return resolve(); 11 | socket.destroy((err) => { 12 | if (err) return reject(err); 13 | return resolve(); 14 | }); 15 | }); 16 | 17 | const serverListen = (server, port) => new Promise((resolve, reject) => { 18 | server.once('error', reject); 19 | 20 | server.listen(port, () => { 21 | server.off('error', reject); 22 | 23 | resolve(server.address().port); 24 | }); 25 | }); 26 | 27 | const connect = (port) => new Promise((resolve, reject) => { 28 | const socket = net.connect({ port }, (err) => { 29 | if (err) return reject(err); 30 | return resolve(socket); 31 | }); 32 | }); 33 | 34 | const closeServer = (server, connections) => new Promise((resolve, reject) => { 35 | if (!server || !server.listening) return resolve(); 36 | Promise.all(connections, destroySocket).then(() => { 37 | server.close((err) => { 38 | if (err) return reject(err); 39 | return resolve(); 40 | }); 41 | }); 42 | }); 43 | 44 | describe('tcp_tunnel.createTunnel', () => { 45 | it('throws error if proxyUrl is not in correct format', () => { 46 | expectThrowsAsync(async () => { await createTunnel('socks://user:password@whatever.com:123', 'localhost:9000'); }, /must have the "http" protocol/); 47 | expectThrowsAsync(async () => { await createTunnel('socks5://user:password@whatever.com', 'localhost:9000'); }, /must have the "http" protocol/); 48 | }); 49 | it('throws error if target is not in correct format', () => { 50 | expectThrowsAsync(async () => { await createTunnel('http://user:password@whatever.com:12'); }, 'Missing target hostname'); 51 | expectThrowsAsync(async () => { await createTunnel('http://user:password@whatever.com:12', null); }, 'Missing target hostname'); 52 | expectThrowsAsync(async () => { await createTunnel('http://user:password@whatever.com:12', ''); }, 'Missing target hostname'); 53 | expectThrowsAsync(async () => { await createTunnel('http://user:password@whatever.com:12', 'whatever'); }, 'Missing target port'); 54 | expectThrowsAsync(async () => { await createTunnel('http://user:password@whatever.com:12', 'whatever:'); }, 'Missing target port'); 55 | expectThrowsAsync(async () => { await createTunnel('http://user:password@whatever.com:12', ':whatever'); }, /Invalid URL/); 56 | }); 57 | it('correctly tunnels to tcp service and then is able to close the connection', () => { 58 | const proxyServerConnections = []; 59 | 60 | const proxyServer = proxy(http.createServer()); 61 | proxyServer.on('connection', (conn) => proxyServerConnections.push(conn)); 62 | 63 | const targetServiceConnections = []; 64 | const targetService = net.createServer(); 65 | targetService.on('connection', (conn) => { 66 | targetServiceConnections.push(conn); 67 | conn.setEncoding('utf8'); 68 | conn.on('data', conn.write); 69 | conn.on('error', (err) => { throw err; }); 70 | }); 71 | 72 | return serverListen(proxyServer, 0) 73 | .then(() => serverListen(targetService, 0)) 74 | .then((targetServicePort) => { 75 | return createTunnel(`http://localhost:${proxyServer.address().port}`, `localhost:${targetServicePort}`); 76 | }) 77 | .then(closeTunnel) 78 | .finally(() => closeServer(proxyServer, proxyServerConnections)) 79 | .finally(() => closeServer(targetService, targetServiceConnections)); 80 | }); 81 | it('correctly tunnels to tcp service and then is able to close the connection when used with callbacks', () => { 82 | const proxyServerConnections = []; 83 | 84 | const proxyServer = proxy(http.createServer()); 85 | proxyServer.on('connection', (conn) => proxyServerConnections.push(conn)); 86 | 87 | const targetServiceConnections = []; 88 | const targetService = net.createServer(); 89 | targetService.on('connection', (conn) => { 90 | targetServiceConnections.push(conn); 91 | conn.setEncoding('utf8'); 92 | conn.on('data', conn.write); 93 | conn.on('error', (err) => { throw err; }); 94 | }); 95 | 96 | return serverListen(proxyServer, 0) 97 | .then(() => serverListen(targetService, 0)) 98 | .then((targetServicePort) => new Promise((resolve, reject) => { 99 | createTunnel(`http://localhost:${proxyServer.address().port}`, `localhost:${targetServicePort}`, {}, (err, tunnel) => { 100 | if (err) return reject(err); 101 | return resolve(tunnel); 102 | }); 103 | }).then((tunnel) => closeTunnel(tunnel, true)) 104 | .then((result) => { 105 | assert.equal(result, true); 106 | })) 107 | .finally(() => closeServer(proxyServer, proxyServerConnections)) 108 | .finally(() => closeServer(targetService, targetServiceConnections)); 109 | }); 110 | it('creates tunnel that is able to transfer data', () => { 111 | let tunnel; 112 | let response = ''; 113 | const expected = [ 114 | 'testA', 115 | 'testB', 116 | 'testC', 117 | ]; 118 | 119 | const proxyServerConnections = []; 120 | 121 | const proxyServer = proxy(http.createServer()); 122 | proxyServer.on('connection', (conn) => proxyServerConnections.push(conn)); 123 | 124 | const targetServiceConnections = []; 125 | const targetService = net.createServer(); 126 | targetService.on('connection', (conn) => { 127 | targetServiceConnections.push(conn); 128 | conn.setEncoding('utf8'); 129 | conn.on('data', conn.write); 130 | conn.on('error', (err) => conn.write(JSON.stringify(err))); 131 | }); 132 | 133 | return serverListen(proxyServer, 0) 134 | .then(() => serverListen(targetService, 0)) 135 | .then((targetServicePort) => createTunnel(`http://localhost:${proxyServer.address().port}`, `localhost:${targetServicePort}`)) 136 | .then((newTunnel) => { 137 | tunnel = newTunnel; 138 | 139 | const { port } = new URL(`connect://${newTunnel}`); 140 | 141 | return connect(port); 142 | }) 143 | .then((connection) => { 144 | connection.setEncoding('utf8'); 145 | connection.on('data', (d) => { response += d; }); 146 | expected.forEach((text) => connection.write(`${text}\r\n`)); 147 | return new Promise((resolve) => setTimeout(() => { 148 | connection.end(); 149 | resolve(tunnel); 150 | }, 500)); 151 | }) 152 | .then(() => { 153 | expect(response.trim().split('\r\n')).to.be.deep.eql(expected); 154 | return closeTunnel(tunnel); 155 | }) 156 | .finally(() => closeServer(proxyServer, proxyServerConnections)) 157 | .finally(() => closeServer(targetService, targetServiceConnections)); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /test/tools.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const { redactUrl } = require('../src/utils/redact_url'); 3 | const { isHopByHopHeader } = require('../src/utils/is_hop_by_hop_header'); 4 | const { parseAuthorizationHeader } = require('../src/utils/parse_authorization_header'); 5 | const { nodeify } = require('../src/utils/nodeify'); 6 | 7 | describe('tools.redactUrl()', () => { 8 | it('works', () => { 9 | // Test that the function lower-cases the schema and path 10 | expect(redactUrl('HTTPS://username:password@WWW.EXAMPLE.COM:1234/path#hash')) 11 | .to.eql('https://username:@www.example.com:1234/path#hash'); 12 | 13 | expect(redactUrl('https://username@www.example.com:1234/path#hash')) 14 | .to.eql('https://username@www.example.com:1234/path#hash'); 15 | 16 | expect(redactUrl('https://username:password@www.example.com:1234/path#hash', '')) 17 | .to.eql('https://username:@www.example.com:1234/path#hash'); 18 | 19 | expect(redactUrl('ftp://@www.example.com/path/path2')) 20 | .to.eql('ftp://www.example.com/path/path2'); 21 | 22 | expect(redactUrl('ftp://www.example.com')) 23 | .to.eql('ftp://www.example.com/'); 24 | 25 | expect(redactUrl('ftp://example.com/')) 26 | .to.eql('ftp://example.com/'); 27 | 28 | expect(redactUrl('http://username:p@%%w0rd@[2001:db8:85a3:8d3:1319:8a2e:370:7348]:12345/')) 29 | .to.eql('http://username:@[2001:db8:85a3:8d3:1319:8a2e:370:7348]:12345/'); 30 | }); 31 | }); 32 | 33 | describe('tools.isHopByHopHeader()', () => { 34 | it('works', () => { 35 | expect(isHopByHopHeader('Connection')).to.eql(true); 36 | expect(isHopByHopHeader('connection')).to.eql(true); 37 | expect(isHopByHopHeader('Proxy-Authorization')).to.eql(true); 38 | expect(isHopByHopHeader('upGrade')).to.eql(true); 39 | 40 | expect(isHopByHopHeader('Host')).to.eql(false); 41 | expect(isHopByHopHeader('Whatever')).to.eql(false); 42 | expect(isHopByHopHeader('')).to.eql(false); 43 | }); 44 | }); 45 | 46 | const authStr = (type, usernameAndPassword) => { 47 | return `${type} ${Buffer.from(usernameAndPassword).toString('base64')}`; 48 | }; 49 | 50 | describe('tools.parseAuthorizationHeader()', () => { 51 | it('works with valid input', () => { 52 | const parse = parseAuthorizationHeader; 53 | 54 | expect(parse(authStr('Basic', 'username:password'))).to.eql({ 55 | type: 'Basic', 56 | username: 'username', 57 | password: 'password', 58 | data: 'dXNlcm5hbWU6cGFzc3dvcmQ=', 59 | }); 60 | 61 | expect(parse(authStr('Basic', 'user1234:password567'))).to.eql({ 62 | type: 'Basic', 63 | username: 'user1234', 64 | password: 'password567', 65 | data: 'dXNlcjEyMzQ6cGFzc3dvcmQ1Njc=', 66 | }); 67 | 68 | expect(parse(authStr('Basic', 'username:pass:with:many:colons'))).to.eql({ 69 | type: 'Basic', 70 | username: 'username', 71 | password: 'pass:with:many:colons', 72 | data: 'dXNlcm5hbWU6cGFzczp3aXRoOm1hbnk6Y29sb25z', 73 | }); 74 | 75 | expect(parse(authStr('Basic', 'username:'))).to.eql({ 76 | type: 'Basic', 77 | username: 'username', 78 | password: '', 79 | data: 'dXNlcm5hbWU6', 80 | }); 81 | 82 | // Do not alter this test, see comment in src/utils/parse_authorization_header.ts 83 | expect(parse(authStr('Basic', 'username'))).to.eql({ 84 | type: 'Basic', 85 | username: 'username', 86 | password: '', 87 | data: 'dXNlcm5hbWU=', 88 | }); 89 | 90 | expect(parse(authStr('Basic', ':'))).to.eql({ 91 | type: 'Basic', 92 | username: '', 93 | password: '', 94 | data: 'Og==', 95 | }); 96 | 97 | expect(parse(authStr('Basic', ':passWord'))).to.eql({ 98 | type: 'Basic', 99 | username: '', 100 | password: 'passWord', 101 | data: 'OnBhc3NXb3Jk', 102 | }); 103 | 104 | expect(parse(authStr('SCRAM-SHA-256', 'something:else'))).to.eql({ 105 | type: 'SCRAM-SHA-256', 106 | data: 'c29tZXRoaW5nOmVsc2U=', 107 | }); 108 | }); 109 | 110 | it('works with invalid input', () => { 111 | const parse = parseAuthorizationHeader; 112 | 113 | expect(parse(null)).to.eql(null); 114 | expect(parse('')).to.eql(null); 115 | expect(parse(' ')).to.eql(null); 116 | 117 | expect(parse('whatever')).to.eql({ 118 | type: '', 119 | data: '', 120 | }); 121 | 122 | expect(parse('bla bla bla')).to.eql({ 123 | type: 'bla', 124 | data: 'bla bla', 125 | }); 126 | 127 | expect(parse(authStr('Basic', ''))).to.eql({ 128 | type: '', 129 | data: '', 130 | }); 131 | 132 | expect(parse('123124')).to.eql({ 133 | type: '', 134 | data: '', 135 | }); 136 | }); 137 | }); 138 | 139 | const asyncFunction = async (throwError) => { 140 | if (throwError) throw new Error('Test error'); 141 | return 123; 142 | }; 143 | 144 | describe('tools.nodeify()', () => { 145 | it('works', async () => { 146 | { 147 | // Test promised result 148 | const promise = asyncFunction(false); 149 | const result = await nodeify(promise, null); 150 | expect(result).to.eql(123); 151 | } 152 | 153 | { 154 | // Test promised exception 155 | const promise = asyncFunction(true); 156 | try { 157 | await nodeify(promise, null); 158 | throw new Error('This should not be reached!'); 159 | } catch (e) { 160 | expect(e.message).to.eql('Test error'); 161 | } 162 | } 163 | 164 | { 165 | // Test callback result 166 | const promise = asyncFunction(false); 167 | await new Promise((resolve) => { 168 | nodeify(promise, (error, result) => { 169 | expect(result).to.eql(123); 170 | resolve(); 171 | }); 172 | }); 173 | } 174 | 175 | { 176 | // Test callback error 177 | const promise = asyncFunction(true); 178 | await new Promise((resolve) => { 179 | nodeify(promise, (error, result) => { 180 | expect(result, undefined); 181 | expect(error.message).to.eql('Test error'); 182 | resolve(); 183 | }); 184 | }); 185 | } 186 | }); 187 | }); 188 | -------------------------------------------------------------------------------- /test/utils/run_locally.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This script runs the proxy with a second upstream proxy locally on port specified by PORT environment variable 3 | * or 8080 if not provided. This is used to manually test the proxy on normal browsing. 4 | * 5 | * node ./build/run_locally.js 6 | * 7 | * Author: Jan Curn (jan@apify.com) 8 | * Copyright(c) 2017 Apify Technologies. All rights reserved. 9 | * 10 | */ 11 | 12 | const http = require('http'); 13 | const proxy = require('proxy'); // eslint-disable-line import/no-extraneous-dependencies 14 | const { Server } = require('../../src/server'); 15 | 16 | // Set up upstream proxy with no auth 17 | const upstreamProxyHttpServer = http.createServer(); 18 | 19 | upstreamProxyHttpServer.on('error', (err) => { 20 | console.error(err.stack || err); 21 | }); 22 | 23 | const upstreamProxyServer = proxy(upstreamProxyHttpServer); 24 | const upstreamProxyPort = process.env.UPSTREAM_PROXY_PORT || 8081; 25 | upstreamProxyServer.listen(process.env.UPSTREAM_PROXY_PORT || 8081, (err) => { 26 | if (err) { 27 | console.error(err.stack || err); 28 | process.exit(1); 29 | } 30 | }); 31 | 32 | // Setup proxy to forward to upstream 33 | const server = new Server({ 34 | port: process.env.PORT || 8080, 35 | // verbose: true, 36 | prepareRequestFunction: () => { 37 | return { requestAuthentication: false, upstreamProxyUrl: `http://127.0.0.1:${upstreamProxyPort}` }; 38 | }, 39 | }); 40 | 41 | server.on('requestFailed', ({ error, request }) => { 42 | console.error(`Request failed (${request ? request.url : 'N/A'}): ${error.stack || error}`); 43 | }); 44 | 45 | server.listen() 46 | .then(() => { 47 | console.log(`Proxy server is running at http://127.0.0.1:${server.port}`); 48 | 49 | setInterval(() => { 50 | console.log(`Stats: ${JSON.stringify(server.stats)}`); 51 | }, 30000); 52 | }) 53 | .catch((err) => { 54 | console.error(err.stack || err); 55 | process.exit(1); 56 | }); 57 | -------------------------------------------------------------------------------- /test/utils/target_server.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const https = require('https'); 3 | const util = require('util'); 4 | const express = require('express'); 5 | const bodyParser = require('body-parser'); 6 | const WebSocket = require('ws'); 7 | const basicAuth = require('basic-auth'); 8 | const _ = require('underscore'); 9 | 10 | /** 11 | * A HTTP server used for testing. It supports HTTPS and web sockets. 12 | */ 13 | class TargetServer { 14 | constructor({ 15 | port, useSsl, sslKey, sslCrt, 16 | }) { 17 | this.port = port; 18 | this.useSsl = useSsl; 19 | 20 | this.app = express(); 21 | 22 | // Parse an HTML body into a string 23 | this.app.use(bodyParser.text({ type: 'text/*', limit: '10MB' })); 24 | 25 | this.app.all('/hello-world', this.allHelloWorld.bind(this)); 26 | this.app.all('/echo-request-info', this.allEchoRequestInfo.bind(this)); 27 | this.app.all('/echo-raw-headers', this.allEchoRawHeaders.bind(this)); 28 | this.app.all('/echo-payload', this.allEchoPayload.bind(this)); 29 | this.app.get('/redirect-to-hello-world', this.getRedirectToHelloWorld.bind(this)); 30 | this.app.get('/get-1m-a-chars-together', this.get1MACharsTogether.bind(this)); 31 | this.app.get('/get-1m-a-chars-streamed', this.get1MACharsStreamed.bind(this)); 32 | this.app.get('/basic-auth', this.getBasicAuth.bind(this)); 33 | this.app.get('/get-non-standard-headers', this.getNonStandardHeaders.bind(this)); 34 | this.app.get('/get-invalid-status-code', this.getInvalidStatusCode.bind(this)); 35 | this.app.get('/get-repeating-headers', this.getRepeatingHeaders.bind(this)); 36 | 37 | this.app.all('*', this.handleHttpRequest.bind(this)); 38 | 39 | if (useSsl) { 40 | this.httpServer = https.createServer({ key: sslKey, cert: sslCrt }, this.app); 41 | } else { 42 | this.httpServer = http.createServer(this.app); 43 | } 44 | 45 | // Web socket server for upgraded HTTP connections 46 | this.wsUpgServer = new WebSocket.Server({ server: this.httpServer }); 47 | this.wsUpgServer.on('connection', this.onWsConnection.bind(this)); 48 | } 49 | 50 | listen() { 51 | return util.promisify(this.httpServer.listen).bind(this.httpServer)(this.port); 52 | } 53 | 54 | allHelloWorld(request, response) { 55 | response.writeHead(200, { 'Content-Type': 'text/plain' }); 56 | response.end('Hello world!'); 57 | } 58 | 59 | allEchoRequestInfo(request, response) { 60 | response.writeHead(200, { 'Content-Type': 'application/json' }); 61 | const result = _.pick(request, 'headers', 'method'); 62 | response.end(JSON.stringify(result)); 63 | } 64 | 65 | allEchoRawHeaders(request, response) { 66 | response.writeHead(200, { 'Content-Type': 'application/json' }); 67 | response.end(JSON.stringify(request.rawHeaders)); 68 | } 69 | 70 | allEchoPayload(request, response) { 71 | response.writeHead(200, { 'Content-Type': request.headers['content-type'] || 'dummy' }); 72 | // console.log('allEchoPayload: ' + request.body.length); 73 | response.end(request.body); 74 | } 75 | 76 | get1MACharsTogether(request, response) { 77 | response.writeHead(200, { 'Content-Type': 'text/plain' }); 78 | response.end(''.padStart(1000 * 1000, 'a')); 79 | } 80 | 81 | get1MACharsStreamed(request, response) { 82 | response.writeHead(200, { 'Content-Type': 'text/plain' }); 83 | for (let i = 0; i < 10000; i++) { 84 | response.write(`${''.padStart(99, 'a')}\n`); 85 | } 86 | response.end(); 87 | } 88 | 89 | getRedirectToHelloWorld(request, response) { 90 | const location = `${this.useSsl ? 'https' : 'http'}://localhost:${this.port}/hello-world`; 91 | response.writeHead(301, { 'Content-Type': 'text/plain', Location: location }); 92 | response.end(); 93 | } 94 | 95 | getBasicAuth(request, response) { 96 | const auth = basicAuth(request); 97 | // Using special char $ to test URI-encoding feature! 98 | // Beware that this is web server auth, not the proxy auth, so this doesn't really test our proxy server 99 | // But it should work anyway 100 | if (!auth || auth.name !== 'john.doe$' || auth.pass !== 'Passwd$') { 101 | response.statusCode = 401; 102 | response.setHeader('WWW-Authenticate', 'Basic realm="MyRealmName"'); 103 | response.end('Unauthorized'); 104 | } else { 105 | response.end('OK'); 106 | } 107 | } 108 | 109 | handleHttpRequest(request, response) { 110 | console.log('Received request'); 111 | 112 | // const message = request.body; 113 | // const remoteAddr = request.socket.remoteAddress; 114 | 115 | response.writeHead(200, { 'Content-Type': 'text/plain' }); 116 | response.end('It works!'); 117 | } 118 | 119 | getNonStandardHeaders(request, response) { 120 | const headers = { 121 | 'Invalid Header With Space': 'HeaderValue1', 122 | 'X-Normal-Header': 'HeaderValue2', 123 | }; 124 | 125 | // This is a regression test for "TypeError: The header content contains invalid characters" 126 | // that occurred in production 127 | if (request.query.skipInvalidHeaderValue !== '1') { 128 | headers['Invalid-Header-Value'] = 'some\value'; 129 | } 130 | 131 | let msg = `HTTP/1.1 200 OK\r\n`; 132 | _.each(headers, (value, key) => { 133 | msg += `${key}: ${value}\r\n`; 134 | }); 135 | msg += `\r\nHello sir!`; 136 | 137 | request.socket.write(msg, () => { 138 | request.socket.end(); 139 | 140 | // Unfortunately calling end() will not close the socket 141 | // if client refuses to close it. Hence calling destroy after a short while. 142 | setTimeout(() => { 143 | request.socket.destroy(); 144 | }, 100); 145 | }); 146 | } 147 | 148 | getInvalidStatusCode(request, response) { 149 | let msg = `HTTP/1.1 55 OK\r\n`; 150 | msg += `\r\nBad status!`; 151 | 152 | request.socket.write(msg, () => { 153 | request.socket.end(); 154 | 155 | // Unfortunately calling end() will not close the socket 156 | // if client refuses to close it. Hence calling destroy after a short while. 157 | setTimeout(() => { 158 | request.socket.destroy(); 159 | }, 100); 160 | }); 161 | } 162 | 163 | getRepeatingHeaders(request, response) { 164 | response.writeHead(200, { 165 | 'Content-Type': 'text/plain', 166 | 'Repeating-Header': ['HeaderValue1', 'HeaderValue2'], 167 | }); 168 | response.end('Hooray!'); 169 | } 170 | 171 | onWsConnection(ws) { 172 | ws.on('error', (err) => { 173 | console.log(`Web socket error: ${err.stack || err}`); 174 | throw err; 175 | }); 176 | 177 | ws.on('close', () => { 178 | // console.log(`Web socket closed`); 179 | }); 180 | 181 | // Simply send data back 182 | ws.on('message', (data) => { 183 | ws.send(`I received: ${data}`); 184 | }); 185 | } 186 | 187 | close() { 188 | return util.promisify(this.httpServer.close).bind(this.httpServer)(); 189 | } 190 | } 191 | 192 | exports.TargetServer = TargetServer; 193 | -------------------------------------------------------------------------------- /test/utils/testing_tcp_service.js: -------------------------------------------------------------------------------- 1 | const net = require('net'); 2 | 3 | // TODO: please move this into ./test dir 4 | 5 | const server = net.createServer(); 6 | 7 | server.on('connection', handleConnection); 8 | 9 | server.listen(9112, () => { 10 | console.log('server listening to %j', server.address()); 11 | }); 12 | 13 | function handleConnection(conn) { 14 | const remoteAddress = `${conn.remoteAddress}:${conn.remotePort}`; 15 | console.log('new client connection from %s', remoteAddress); 16 | 17 | conn.setEncoding('utf8'); 18 | 19 | conn.on('data', onConnData); 20 | conn.on('close', onConnClose); 21 | conn.on('error', onConnError); 22 | 23 | function onConnData(d) { 24 | console.log('connection data from %s: %j', remoteAddress, d); 25 | conn.write(d); 26 | } 27 | 28 | function onConnClose() { 29 | console.log('connection from %s closed', remoteAddress); 30 | } 31 | 32 | function onConnError(err) { 33 | console.log('Connection %s error: %s', remoteAddress, err.message); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /test/utils/throws_async.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | 3 | /** 4 | * Expect an async function to throw 5 | * @param {*} func Async function to be tested 6 | * @param {*} errorMessage Error message to be expected, can be a string or a RegExp 7 | */ 8 | const expectThrowsAsync = async (func, errorMessage) => { 9 | let error = null; 10 | try { 11 | await func(); 12 | } catch (err) { 13 | error = err; 14 | } 15 | expect(error).to.be.an('Error'); 16 | if (errorMessage) { 17 | if (errorMessage instanceof RegExp) { 18 | expect(error.message).to.match(errorMessage); 19 | } else { 20 | expect(error.message).to.contain(errorMessage); 21 | } 22 | } 23 | }; 24 | 25 | exports.expectThrowsAsync = expectThrowsAsync; 26 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "src", 5 | "jest.config.ts" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@apify/tsconfig", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": [ 7 | "src" 8 | ] 9 | } 10 | --------------------------------------------------------------------------------