diff --git a/.gitignore b/.gitignore index 96e8f74..74b9196 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /node_modules +/?.?s +/dist # mac files *.DS_Store diff --git a/History.md b/History.md deleted file mode 100644 index b0266b5..0000000 --- a/History.md +++ /dev/null @@ -1,96 +0,0 @@ - -3.0.1 / 2017-09-18 -================== - - * update "agent-base" to v4.1.0 - -3.0.0 / 2017-06-13 -================== - - * [BREAKING] drop support for Node < 4 - * update deps, remove `extend` dependency - * rename `socks-proxy-agent.js` to `index.js` - -2.1.1 / 2017-06-13 -================== - - * fix a bug where `close` would emit before `end` - * use "raw-body" module for tests - * prettier - -2.1.0 / 2017-05-24 -================== - - * DRY post-lookup logic - * Fix an error in readme (#13, @599316527) - * travis: test node v5 - * travis: test iojs v1, 2, 3 and node.js v4 - * test: use ssl-cert-snakeoil cert files - * Authentication support (#9, @baryshev) - -2.0.0 / 2015-07-10 -================== - - * API CHANGE! Removed `secure` boolean second argument in constructor - * upgrade to "agent-base" v2 API - * package: update "extend" to v3 - -1.0.2 / 2015-07-01 -================== - - * remove "v4a" from description - * socks-proxy-agent: cast `port` to a Number - * travis: attempt to make node v0.8 work - * travis: test node v0.12, don't test v0.11 - * test: pass `rejectUnauthorized` as a proxy opt - * test: catch http.ClientRequest errors - * test: add self-signed SSL server cert files - * test: refactor to use local SOCKS, HTTP and HTTPS servers - * README: use SVG for Travis-CI badge - -1.0.1 / 2015-03-01 -================== - - * switched from using "socks-client" to "socks" (#5, @JoshGlazebrook) - -1.0.0 / 2015-02-11 -================== - - * add client-side DNS lookup logic for 4 and 5 version socks proxies - * remove dead `onproxyconnect()` code function - * use a switch statement to decide the socks `version` - * refactor to use "socks-client" instead of "rainbowsocks" - * package: remove "rainbowsocks" dependency - * package: allow any "mocha" v2 - -0.1.2 / 2014-06-11 -================== - - * package: update "rainbowsocks" to v0.1.2 - * travis: don't test node v0.9 - -0.1.1 / 2014-04-09 -================== - - * package: update outdated dependencies - * socks-proxy-agent: pass `secure` flag when no `new` - * socks-proxy-agent: small code cleanup - -0.1.0 / 2013-11-19 -================== - - * add .travis.yml file - * socks-proxy-agent: properly mix in the proxy options - * socks-proxy-agent: coerce the `secureEndpoint` into a Boolean - * socks-proxy-agent: use "extend" module - * socks-proxy-agent: update to "agent-base" v1 API - -0.0.2 / 2013-07-24 -================== - - * socks-proxy-agent: properly set the `defaultPort` property - -0.0.1 / 2013-07-11 -================== - - * Initial release diff --git a/index.js b/index.js deleted file mode 100644 index 7ac6629..0000000 --- a/index.js +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Module dependencies. - */ - -var tls; // lazy-loaded... -var url = require('url'); -var dns = require('dns'); -var Agent = require('agent-base'); -var SocksClient = require('socks').SocksClient; -var inherits = require('util').inherits; - -/** - * Module exports. - */ - -module.exports = SocksProxyAgent; - -/** - * The `SocksProxyAgent`. - * - * @api public - */ - -function SocksProxyAgent(opts) { - if (!(this instanceof SocksProxyAgent)) return new SocksProxyAgent(opts); - if ('string' == typeof opts) opts = url.parse(opts); - if (!opts) - throw new Error( - 'a SOCKS proxy server `host` and `port` must be specified!' - ); - Agent.call(this, opts); - - var proxy = Object.assign({}, opts); - - // prefer `hostname` over `host`, because of `url.parse()` - proxy.host = proxy.hostname || proxy.host; - - // SOCKS doesn't *technically* have a default port, but this is - // the same default that `curl(1)` uses - proxy.port = +proxy.port || 1080; - - if (proxy.host && proxy.path) { - // if both a `host` and `path` are specified then it's most likely the - // result of a `url.parse()` call... we need to remove the `path` portion so - // that `net.connect()` doesn't attempt to open that as a unix socket file. - delete proxy.path; - delete proxy.pathname; - } - - // figure out if we want socks v4 or v5, based on the "protocol" used. - // Defaults to 5. - proxy.lookup = false; - switch (proxy.protocol) { - case 'socks4:': - proxy.lookup = true; - // pass through - case 'socks4a:': - proxy.version = 4; - break; - case 'socks5:': - proxy.lookup = true; - // pass through - case 'socks:': // no version specified, default to 5h - case 'socks5h:': - proxy.version = 5; - break; - default: - throw new TypeError( - 'A "socks" protocol must be specified! Got: ' + proxy.protocol - ); - } - - if (proxy.auth) { - var auth = proxy.auth.split(':'); - proxy.authentication = { username: auth[0], password: auth[1] }; - proxy.userid = auth[0]; - } - this.proxy = proxy; -} -inherits(SocksProxyAgent, Agent); - -/** - * Initiates a SOCKS connection to the specified SOCKS proxy server, - * which in turn connects to the specified remote host and port. - * - * @api public - */ - -SocksProxyAgent.prototype.callback = function connect(req, opts, fn) { - var proxy = this.proxy; - - // called once the SOCKS proxy has connected to the specified remote endpoint - function onhostconnect(err, result) { - if (err) return fn(err); - - var socket = result.socket; - - var s = socket; - if (opts.secureEndpoint) { - // since the proxy is connecting to an SSL server, we have - // to upgrade this socket connection to an SSL connection - if (!tls) tls = require('tls'); - opts.socket = socket; - opts.servername = opts.servername || opts.host; - opts.host = null; - opts.hostname = null; - opts.port = null; - s = tls.connect(opts); - } - - fn(null, s); - } - - // called for the `dns.lookup()` callback - function onlookup(err, ip) { - if (err) return fn(err); - options.destination.host = ip; - SocksClient.createConnection(options, onhostconnect); - } - - var options = { - proxy: { - ipaddress: proxy.host, - port: +proxy.port, - type: proxy.version - }, - destination: { - port: +opts.port - }, - command: 'connect' - }; - - if (proxy.authentication) { - options.proxy.userId = proxy.userid; - options.proxy.password = proxy.authentication.password; - } - - if (proxy.lookup) { - // client-side DNS resolution for "4" and "5" socks proxy versions - dns.lookup(opts.host, onlookup); - } else { - // proxy hostname DNS resolution for "4a" and "5h" socks proxy servers - onlookup(null, opts.host); - } -}; diff --git a/package.json b/package.json index e2e2ebe..113ecf1 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,18 @@ "name": "socks-proxy-agent", "version": "4.0.2", "description": "A SOCKS proxy `http.Agent` implementation for HTTP and HTTPS", - "main": "./index.js", - "scripts": { - "test": "mocha --reporter spec" - }, + "main": "dist/index", + "typings": "dist/index", "files": [ - "index.js" + "dist" ], + "scripts": { + "prebuild": "rimraf dist", + "build": "tsc", + "test": "mocha --reporter spec", + "test-lint": "eslint src --ext .js,.ts", + "prepublishOnly": "npm run build" + }, "repository": { "type": "git", "url": "git://github.com/TooTallNate/node-socks-proxy-agent.git" @@ -17,6 +22,8 @@ "socks", "socks4", "socks4a", + "socks5", + "socks5h", "proxy", "http", "https", @@ -28,13 +35,28 @@ "url": "https://github.com/TooTallNate/node-socks-proxy-agent/issues" }, "dependencies": { - "agent-base": "5", - "socks": "^2.3.2" + "agent-base": "6", + "debug": "4", + "socks": "^2.3.3" }, "devDependencies": { - "mocha": "^5.1.0", + "@types/debug": "4", + "@types/node": "^12.12.11", + "@typescript-eslint/eslint-plugin": "1.6.0", + "@typescript-eslint/parser": "1.1.0", + "eslint": "5.16.0", + "eslint-config-airbnb": "17.1.0", + "eslint-config-prettier": "4.1.0", + "eslint-import-resolver-typescript": "1.1.1", + "eslint-plugin-import": "2.16.0", + "eslint-plugin-jsx-a11y": "6.2.1", + "eslint-plugin-react": "7.12.4", + "mocha": "^6.2.2", + "proxy": "1", "raw-body": "^2.3.2", - "socksv5": "TooTallNate/socksv5#fix/dstSock-close-event" + "rimraf": "^3.0.0", + "socksv5": "TooTallNate/socksv5#fix/dstSock-close-event", + "typescript": "^3.5.3" }, "engines": { "node": ">= 6" diff --git a/src/agent.ts b/src/agent.ts new file mode 100644 index 0000000..46d70f7 --- /dev/null +++ b/src/agent.ts @@ -0,0 +1,204 @@ +import dns from 'dns'; +import net from 'net'; +import tls from 'tls'; +import url from 'url'; +import createDebug from 'debug'; +import { Agent, ClientRequest, RequestOptions } from 'agent-base'; +import { SocksClient, SocksProxy, SocksClientOptions } from 'socks'; +import { SocksProxyAgentOptions } from '.'; + +const debug = createDebug('socks-proxy-agent'); + +function dnsLookup(host: string): Promise { + return new Promise((resolve, reject) => { + dns.lookup(host, (err, res) => { + if (err) { + reject(err); + } else { + resolve(res); + } + }); + }); +} + +function parseSocksProxy( + opts: SocksProxyAgentOptions +): { lookup: boolean; proxy: SocksProxy } { + let port = 0; + let lookup = false; + let type: SocksProxy['type'] = 5; + + // Prefer `hostname` over `host`, because of `url.parse()` + const host = opts.hostname || opts.host; + if (!host) { + throw new TypeError('No "host"'); + } + + if (typeof opts.port === 'number') { + port = opts.port; + } else if (typeof opts.port === 'string') { + port = parseInt(opts.port, 10); + } + + // From RFC 1928, Section 3: https://tools.ietf.org/html/rfc1928#section-3 + // "The SOCKS service is conventionally located on TCP port 1080" + if (!port) { + port = 1080; + } + + // figure out if we want socks v4 or v5, based on the "protocol" used. + // Defaults to 5. + if (opts.protocol) { + switch (opts.protocol) { + case 'socks4:': + lookup = true; + // pass through + case 'socks4a:': + type = 4; + break; + case 'socks5:': + lookup = true; + // pass through + case 'socks:': // no version specified, default to 5h + case 'socks5h:': + type = 5; + break; + default: + throw new TypeError( + `A "socks" protocol must be specified! Got: ${opts.protocol}` + ); + } + } + + if (typeof opts.type !== 'undefined') { + if (opts.type === 4 || opts.type === 5) { + type = opts.type; + } else { + throw new TypeError(`"type" must be 4 or 5, got: ${opts.type}`); + } + } + + const proxy: SocksProxy = { + host, + port, + type + }; + + let userId = opts.userId; + let password = opts.password; + if (opts.auth) { + const auth = opts.auth.split(':'); + userId = auth[0]; + password = auth[1]; + } + if (userId) { + Object.defineProperty(proxy, 'userId', { + value: userId, + enumerable: false + }); + } + if (password) { + Object.defineProperty(proxy, 'password', { + value: password, + enumerable: false + }); + } + + return { lookup, proxy }; +} + +/** + * The `SocksProxyAgent`. + * + * @api public + */ +export default class SocksProxyAgent extends Agent { + private lookup: boolean; + private proxy: SocksProxy; + + constructor(_opts: string | SocksProxyAgentOptions) { + let opts: SocksProxyAgentOptions; + if (typeof _opts === 'string') { + opts = url.parse(_opts); + } else { + opts = _opts; + } + if (!opts) { + throw new TypeError( + 'a SOCKS proxy server `host` and `port` must be specified!' + ); + } + super(opts); + + const parsedProxy = parseSocksProxy(opts); + this.lookup = parsedProxy.lookup; + this.proxy = parsedProxy.proxy; + } + + /** + * Initiates a SOCKS connection to the specified SOCKS proxy server, + * which in turn connects to the specified remote host and port. + * + * @api protected + */ + async callback( + req: ClientRequest, + opts: RequestOptions + ): Promise { + const { lookup, proxy } = this; + let { host, port } = opts; + + if (!host) { + throw new Error('No `host` defined!'); + } + + if (lookup) { + // Client-side DNS resolution for "4" and "5" socks proxy versions. + host = await dnsLookup(host); + } + + const socksOpts: SocksClientOptions = { + proxy, + destination: { host, port }, + command: 'connect' + }; + debug('Creating socks proxy connection: %o', socksOpts); + const { socket } = await SocksClient.createConnection(socksOpts); + debug('Successfully created socks proxy connection'); + + if (opts.secureEndpoint) { + const servername = opts.servername || opts.host; + if (!servername) { + throw new Error('Could not determine "servername"'); + } + // The proxy is connecting to a TLS server, so upgrade + // this socket connection to a TLS connection. + debug('Upgrading socket connection to TLS'); + return tls.connect({ + ...omit(opts, 'host', 'hostname', 'path', 'port'), + socket, + servername + }); + } + + return socket; + } +} + +function omit( + obj: T, + ...keys: K +): { + [K2 in Exclude]: T[K2]; +} { + const ret = {} as { + [K in keyof typeof obj]: (typeof obj)[K]; + }; + let key: keyof typeof obj; + for (key in obj) { + if (!keys.includes(key)) { + ret[key] = obj[key]; + } + } + return ret; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..5d364be --- /dev/null +++ b/src/index.ts @@ -0,0 +1,29 @@ +import { Url } from 'url'; +import { SocksProxy } from 'socks'; +import { AgentOptions } from 'agent-base'; +import _SocksProxyAgent from './agent'; + +function createSocksProxyAgent( + opts: string | createSocksProxyAgent.SocksProxyAgentOptions +): _SocksProxyAgent { + return new _SocksProxyAgent(opts); +} + +namespace createSocksProxyAgent { + interface BaseSocksProxyAgentOptions { + host?: string | null; + port?: string | number | null; + } + + export interface SocksProxyAgentOptions + extends AgentOptions, + BaseSocksProxyAgentOptions, + Partial> {} + + export type SocksProxyAgent = _SocksProxyAgent; + export const SocksProxyAgent = _SocksProxyAgent; + + createSocksProxyAgent.prototype = _SocksProxyAgent.prototype; +} + +export = createSocksProxyAgent; diff --git a/test/test.js b/test/test.js index 3d3fc62..87cd313 100644 --- a/test/test.js +++ b/test/test.js @@ -2,19 +2,22 @@ * Module dependencies. */ -var fs = require('fs'); -var url = require('url'); -var http = require('http'); -var https = require('https'); -var assert = require('assert'); -var socks = require('socksv5'); -var getRawBody = require('raw-body'); -var SocksProxyAgent = require('../'); +let fs = require('fs'); +let url = require('url'); +let http = require('http'); +let https = require('https'); +let assert = require('assert'); +let socks = require('socksv5'); +let getRawBody = require('raw-body'); +let SocksProxyAgent = require('../'); describe('SocksProxyAgent', function() { - var httpServer, httpPort; - var httpsServer, httpsPort; - var socksServer, socksPort; + let httpServer; + let httpPort; + let httpsServer; + let httpsPort; + let socksServer; + let socksPort; before(function(done) { // setup SOCKS proxy server @@ -39,9 +42,9 @@ describe('SocksProxyAgent', function() { before(function(done) { // setup target SSL HTTPS server - var options = { - key: fs.readFileSync(__dirname + '/ssl-cert-snakeoil.key'), - cert: fs.readFileSync(__dirname + '/ssl-cert-snakeoil.pem') + let options = { + key: fs.readFileSync(`${__dirname}/ssl-cert-snakeoil.key`), + cert: fs.readFileSync(`${__dirname}/ssl-cert-snakeoil.pem`) }; httpsServer = https.createServer(options); httpsServer.listen(function() { @@ -78,13 +81,13 @@ describe('SocksProxyAgent', function() { }); }); it('should accept a "string" proxy argument', function() { - var agent = new SocksProxyAgent('socks://127.0.0.1:' + socksPort); + let agent = new SocksProxyAgent(`socks://127.0.0.1:${socksPort}`); assert.equal('127.0.0.1', agent.proxy.host); assert.equal(socksPort, agent.proxy.port); }); it('should accept a `url.parse()` result object argument', function() { - var opts = url.parse('socks://127.0.0.1:' + socksPort); - var agent = new SocksProxyAgent(opts); + let opts = url.parse(`socks://127.0.0.1:${socksPort}`); + let agent = new SocksProxyAgent(opts); assert.equal('127.0.0.1', agent.proxy.host); assert.equal(socksPort, agent.proxy.port); }); @@ -98,15 +101,15 @@ describe('SocksProxyAgent', function() { res.end(JSON.stringify(req.headers)); }); - var agent = new SocksProxyAgent('socks://127.0.0.1:' + socksPort); - var opts = url.parse('http://127.0.0.1:' + httpPort + '/foo'); + let agent = new SocksProxyAgent(`socks://127.0.0.1:${socksPort}`); + let opts = url.parse(`http://127.0.0.1:${httpPort}/foo`); opts.agent = agent; opts.headers = { foo: 'bar' }; - var req = http.get(opts, function(res) { + let req = http.get(opts, function(res) { assert.equal(404, res.statusCode); getRawBody(res, 'utf8', function(err, buf) { if (err) return done(err); - var data = JSON.parse(buf); + let data = JSON.parse(buf); assert.equal('bar', data.foo); done(); }); @@ -123,17 +126,17 @@ describe('SocksProxyAgent', function() { res.end(JSON.stringify(req.headers)); }); - var agent = new SocksProxyAgent('socks://127.0.0.1:' + socksPort); - var opts = url.parse('https://127.0.0.1:' + httpsPort + '/foo'); + let agent = new SocksProxyAgent(`socks://127.0.0.1:${socksPort}`); + let opts = url.parse(`https://127.0.0.1:${httpsPort}/foo`); opts.agent = agent; opts.rejectUnauthorized = false; opts.headers = { foo: 'bar' }; - var req = https.get(opts, function(res) { + let req = https.get(opts, function(res) { assert.equal(404, res.statusCode); getRawBody(res, 'utf8', function(err, buf) { if (err) return done(err); - var data = JSON.parse(buf); + let data = JSON.parse(buf); assert.equal('bar', data.foo); done(); }); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..63692a7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "module": "CommonJS", + "target": "es2015", + "esModuleInterop": true, + "lib": ["esnext"], + "outDir": "dist", + "sourceMap": true, + "declaration": true, + "typeRoots": [ + "./@types", + "./node_modules/@types" + ] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +}