diff --git a/.changeset/tidy-deers-glow.md b/.changeset/tidy-deers-glow.md new file mode 100644 index 00000000..1bfbd281 --- /dev/null +++ b/.changeset/tidy-deers-glow.md @@ -0,0 +1,10 @@ +--- +'https-proxy-agent': minor +'socks-proxy-agent': minor +'http-proxy-agent': minor +'pac-proxy-agent': minor +'proxy-agent': minor +'agent-base': minor +--- + +Add support for core `keepAlive: true` diff --git a/packages/agent-base/src/index.ts b/packages/agent-base/src/index.ts index ec80223f..d8221d0f 100644 --- a/packages/agent-base/src/index.ts +++ b/packages/agent-base/src/index.ts @@ -33,8 +33,12 @@ export abstract class Agent extends http.Agent { _protocol?: string; _currentSocket?: Duplex; - constructor() { - super(); + // Set by `http.Agent` - missing from `@types/node` + options!: Partial; + keepAlive!: boolean; + + constructor(opts?: http.AgentOptions) { + super(opts); this._defaultPort = undefined; this._protocol = undefined; } @@ -57,8 +61,8 @@ export abstract class Agent extends http.Agent { .then(() => this.connect(req, o)) .then((socket) => { if (socket instanceof http.Agent) { - // @ts-expect-error `createSocket()` isn't defined in `@types/node` - return socket.createSocket(req, o, cb); + // @ts-expect-error `addRequest()` isn't defined in `@types/node` + return socket.addRequest(req, o); } this._currentSocket = socket; // @ts-expect-error `createSocket()` isn't defined in `@types/node` @@ -77,7 +81,7 @@ export abstract class Agent extends http.Agent { if (typeof this._defaultPort === 'number') { return this._defaultPort; } - const port = isSecureEndpoint() ? 443 : 80; + const port = this.protocol === 'https:' ? 443 : 80; return port; } diff --git a/packages/agent-base/test/test.ts b/packages/agent-base/test/test.ts index 10192243..9004ee59 100644 --- a/packages/agent-base/test/test.ts +++ b/packages/agent-base/test/test.ts @@ -1,39 +1,20 @@ +import assert from 'assert'; import * as fs from 'fs'; import * as net from 'net'; import * as tls from 'tls'; -import * as url from 'url'; import * as http from 'http'; import * as https from 'https'; -import assert from 'assert'; -import listen from 'async-listen'; -import { Agent, AgentConnectOpts } from '../src'; +import { once } from 'events'; +import { listen } from 'async-listen'; +import { Agent, AgentConnectOpts, req, json } from '../src'; const sleep = (n: number) => new Promise((r) => setTimeout(r, n)); -const req = (opts: https.RequestOptions): Promise => - new Promise((resolve, reject) => { - (opts.protocol === 'https:' ? https : http) - .request(opts, resolve) - .once('error', reject) - .end(); - }); - const sslOptions = { key: fs.readFileSync(`${__dirname}/ssl-cert-snakeoil.key`), cert: fs.readFileSync(`${__dirname}/ssl-cert-snakeoil.pem`), }; -function json(res: http.IncomingMessage): Promise> { - return new Promise((resolve) => { - let data = ''; - res.setEncoding('utf8'); - res.on('data', (b) => { - data += b; - }); - res.on('end', () => resolve(JSON.parse(data))); - }); -} - describe('Agent (TypeScript)', () => { describe('subclass', () => { it('should be extendable (direct return)', () => { @@ -91,8 +72,9 @@ describe('Agent (TypeScript)', () => { const { port } = addr; try { - const info = url.parse(`http://127.0.0.1:${port}/foo`); - const res = await req({ agent, ...info }); + const res = await req(`http://127.0.0.1:${port}/foo`, { + agent, + }); assert.equal('bar', res.headers['x-foo']); assert.equal('/foo', res.headers['x-url']); assert(gotReq); @@ -130,8 +112,9 @@ describe('Agent (TypeScript)', () => { agent.defaultPort = port; try { - const info = url.parse(`http://127.0.0.1:${port}/foo`); - const res = await req({ agent, ...info }); + const res = await req(`http://127.0.0.1:${port}/foo`, { + agent, + }); const body = await json(res); assert.equal(body.host, '127.0.0.1'); } finally { @@ -172,8 +155,9 @@ describe('Agent (TypeScript)', () => { const { port } = addr; try { - const info = url.parse(`http://127.0.0.1:${port}/foo`); - const res = await req({ agent, ...info }); + const res = await req(`http://127.0.0.1:${port}/foo`, { + agent, + }); assert.equal('bar', res.headers['x-foo']); assert.equal('/foo', res.headers['x-url']); assert(gotReq); @@ -197,8 +181,7 @@ describe('Agent (TypeScript)', () => { const agent = new MyAgent(); try { - const info = url.parse('http://127.0.0.1/throws'); - await req({ agent, ...info }); + await req('http://127.0.0.1/throws', { agent }); } catch (err: unknown) { gotError = true; assert.equal((err as Error).message, 'bad'); @@ -223,8 +206,7 @@ describe('Agent (TypeScript)', () => { const agent = new MyAgent(); try { - const info = url.parse('http://127.0.0.1/throws'); - await req({ agent, ...info }); + await req('http://127.0.0.1/throws', { agent }); } catch (err: unknown) { gotError = true; assert.equal((err as Error).message, 'bad'); @@ -233,6 +215,72 @@ describe('Agent (TypeScript)', () => { assert(gotError); assert(gotCallback); }); + + it('should support `keepAlive: true`', async () => { + let reqCount1 = 0; + let reqCount2 = 0; + let connectCount = 0; + + class MyAgent extends Agent { + async connect( + _req: http.ClientRequest, + opts: AgentConnectOpts + ) { + connectCount++; + assert(opts.secureEndpoint === false); + return net.connect(opts); + } + } + const agent = new MyAgent({ keepAlive: true }); + + const server1 = http.createServer((req, res) => { + expect(req.headers.connection).toEqual('keep-alive'); + reqCount1++; + res.end(); + }); + const addr1 = (await listen(server1)) as URL; + + const server2 = http.createServer((req, res) => { + expect(req.headers.connection).toEqual('keep-alive'); + reqCount2++; + res.end(); + }); + const addr2 = (await listen(server2)) as URL; + + try { + const res = await req(new URL('/foo', addr1), { agent }); + expect(reqCount1).toEqual(1); + expect(connectCount).toEqual(1); + expect(res.headers.connection).toEqual('keep-alive'); + res.resume(); + const s1 = res.socket; + + await once(s1, 'free'); + + const res2 = await req(new URL('/another', addr1), { agent }); + expect(reqCount1).toEqual(2); + expect(connectCount).toEqual(1); + expect(res2.headers.connection).toEqual('keep-alive'); + assert(res2.socket === s1); + + res2.resume(); + await once(res2.socket, 'free'); + + // This is a different host, so a new socket should be used + const res3 = await req(new URL('/another', addr2), { agent }); + expect(reqCount2).toEqual(1); + expect(connectCount).toEqual(2); + expect(res3.headers.connection).toEqual('keep-alive'); + assert(res3.socket !== s1); + + res3.resume(); + await once(res3.socket, 'free'); + } finally { + agent.destroy(); + server1.close(); + server2.close(); + } + }); }); describe('"https" module', () => { @@ -268,11 +316,9 @@ describe('Agent (TypeScript)', () => { const { port } = addr; try { - const info = url.parse(`https://127.0.0.1:${port}/foo`); - const res = await req({ + const res = await req(`https://127.0.0.1:${port}/foo`, { agent, rejectUnauthorized: false, - ...info, }); assert.equal('bar', res.headers['x-foo']); assert.equal('/foo', res.headers['x-url']); @@ -328,11 +374,9 @@ describe('Agent (TypeScript)', () => { const { port } = addr; try { - const info = url.parse(`https://127.0.0.1:${port}/foo`); - const res = await req({ + const res = await req(`https://127.0.0.1:${port}/foo`, { agent: agent1, rejectUnauthorized: false, - ...info, }); assert.equal('bar', res.headers['x-foo']); assert.equal('/foo', res.headers['x-url']); @@ -376,11 +420,9 @@ describe('Agent (TypeScript)', () => { agent.defaultPort = port; try { - const info = url.parse(`https://127.0.0.1:${port}/foo`); - const res = await req({ + const res = await req(`https://127.0.0.1:${port}/foo`, { agent, rejectUnauthorized: false, - ...info, }); const body = await json(res); assert.equal(body.host, '127.0.0.1'); @@ -389,5 +431,56 @@ describe('Agent (TypeScript)', () => { server.close(); } }); + + it('should support `keepAlive: true`', async () => { + let gotReq = false; + let connectCount = 0; + + class MyAgent extends Agent { + async connect( + _req: http.ClientRequest, + opts: AgentConnectOpts + ) { + connectCount++; + assert(opts.secureEndpoint === true); + return tls.connect(opts); + } + } + const agent = new MyAgent({ keepAlive: true }); + + const server = https.createServer(sslOptions, (req, res) => { + gotReq = true; + res.end(); + }); + const addr = (await listen(server)) as URL; + + try { + const res = await req(new URL('/foo', addr), { + agent, + rejectUnauthorized: false, + }); + assert(gotReq); + expect(connectCount).toEqual(1); + expect(res.headers.connection).toEqual('keep-alive'); + res.resume(); + const s1 = res.socket; + + await once(s1, 'free'); + + const res2 = await req(new URL('/another', addr), { + agent, + rejectUnauthorized: false, + }); + expect(connectCount).toEqual(1); + expect(res2.headers.connection).toEqual('keep-alive'); + assert(res2.socket === s1); + + res2.resume(); + await once(res2.socket, 'free'); + } finally { + agent.destroy(); + server.close(); + } + }); }); }); diff --git a/packages/http-proxy-agent/src/index.ts b/packages/http-proxy-agent/src/index.ts index fe069849..06556104 100644 --- a/packages/http-proxy-agent/src/index.ts +++ b/packages/http-proxy-agent/src/index.ts @@ -15,12 +15,14 @@ type ConnectOptsMap = { https: Omit; }; -export type HttpProxyAgentOptions = { +type ConnectOpts = { [P in keyof ConnectOptsMap]: Protocol extends P ? ConnectOptsMap[P] : never; }[keyof ConnectOptsMap]; +export type HttpProxyAgentOptions = ConnectOpts & http.AgentOptions; + interface HttpProxyAgentClientRequest extends http.ClientRequest { outputData?: { data: string; @@ -48,7 +50,7 @@ export class HttpProxyAgent extends Agent { } constructor(proxy: Uri | URL, opts?: HttpProxyAgentOptions) { - super(); + super(opts); this.proxy = typeof proxy === 'string' ? new URL(proxy) : proxy; debug('Creating new HttpProxyAgent instance: %o', this.proxy.href); @@ -99,6 +101,13 @@ export class HttpProxyAgent extends Agent { ); } + if (!req.hasHeader('proxy-connection')) { + req.setHeader( + 'Proxy-Connection', + this.keepAlive ? 'Keep-Alive' : 'close' + ); + } + // Create a socket connection to the proxy server. let socket: net.Socket; if (this.secureProxy) { diff --git a/packages/https-proxy-agent/src/index.ts b/packages/https-proxy-agent/src/index.ts index 5adf6c6e..7ff413f3 100644 --- a/packages/https-proxy-agent/src/index.ts +++ b/packages/https-proxy-agent/src/index.ts @@ -23,9 +23,10 @@ type ConnectOpts = { : never; }[keyof ConnectOptsMap]; -export type HttpsProxyAgentOptions = ConnectOpts & { - headers?: OutgoingHttpHeaders; -}; +export type HttpsProxyAgentOptions = ConnectOpts & + http.AgentOptions & { + headers?: OutgoingHttpHeaders; + }; /** * The `HttpsProxyAgent` implements an HTTP Agent subclass that connects to @@ -51,7 +52,8 @@ export class HttpsProxyAgent extends Agent { } constructor(proxy: Uri | URL, opts?: HttpsProxyAgentOptions) { - super(); + super(opts); + this.options = { path: undefined }; this.proxy = typeof proxy === 'string' ? new URL(proxy) : proxy; this.proxyHeaders = opts?.headers ?? {}; debug('Creating new HttpsProxyAgent instance: %o', this.proxy.href); @@ -100,7 +102,7 @@ export class HttpsProxyAgent extends Agent { } const headers: OutgoingHttpHeaders = { ...this.proxyHeaders }; - let host = net.isIPv6(opts.host) ? `[${opts.host}]` : opts.host; + const host = net.isIPv6(opts.host) ? `[${opts.host}]` : opts.host; let payload = `CONNECT ${host}:${opts.port} HTTP/1.1\r\n`; // Inject the `Proxy-Authorization` header if necessary. @@ -113,15 +115,13 @@ export class HttpsProxyAgent extends Agent { ).toString('base64')}`; } - // The `Host` header should only include the port - // number when it is not the default port. - const { port, secureEndpoint } = opts; - if (!isDefaultPort(port, secureEndpoint)) { - host += `:${port}`; - } - headers.Host = host; + headers.Host = `${host}:${opts.port}`; - headers.Connection = 'close'; + if (!headers['Proxy-Connection']) { + headers['Proxy-Connection'] = this.keepAlive + ? 'Keep-Alive' + : 'close'; + } for (const name of Object.keys(headers)) { payload += `${name}: ${headers[name]}\r\n`; } @@ -140,16 +140,11 @@ export class HttpsProxyAgent extends Agent { // this socket connection to a TLS connection. debug('Upgrading socket connection to TLS'); const servername = opts.servername || opts.host; - const s = tls.connect({ + return tls.connect({ ...omit(opts, 'host', 'path', 'port'), socket, - servername, + servername: net.isIP(servername) ? undefined : servername, }); - //console.log(s); - - //s.write('GET /foo HTTP/1.1\r\n\r\n'); - //await new Promise(r => setTimeout(r, 5000)); - return s; } return socket; @@ -191,10 +186,6 @@ function resume(socket: net.Socket | tls.TLSSocket): void { socket.resume(); } -function isDefaultPort(port: number, secure: boolean): boolean { - return Boolean((!secure && port === 80) || (secure && port === 443)); -} - function isHTTPS(protocol?: string | null): boolean { return typeof protocol === 'string' ? /^https:?$/i.test(protocol) : false; } diff --git a/packages/https-proxy-agent/test/test.ts b/packages/https-proxy-agent/test/test.ts index 8ecb8210..9e76c16f 100644 --- a/packages/https-proxy-agent/test/test.ts +++ b/packages/https-proxy-agent/test/test.ts @@ -53,6 +53,11 @@ describe('HttpsProxyAgent', () => { sslProxyUrl = (await listen(sslProxy)) as URL; }); + beforeEach(() => { + server.removeAllListeners('request'); + sslServer.removeAllListeners('request'); + }); + // shut down the test HTTP servers afterAll(() => { server.close(); @@ -189,6 +194,31 @@ describe('HttpsProxyAgent', () => { assert.equal('bar', req.headers.foo); socket.destroy(); }); + + it('should work with `keepAlive: true`', async () => { + server.on('request', (req, res) => { + res.end(JSON.stringify(req.headers)); + }); + + const agent = new HttpsProxyAgent(proxyUrl, { keepAlive: true }); + + try { + const res = await req(serverUrl, { agent }); + expect(res.headers.connection).toEqual('keep-alive'); + res.resume(); + const s1 = res.socket; + await once(s1, 'free'); + + const res2 = await req(serverUrl, { agent }); + expect(res2.headers.connection).toEqual('keep-alive'); + res2.resume(); + const s2 = res2.socket; + assert(s1 === s2); + await once(s2, 'free'); + } finally { + agent.destroy(); + } + }); }); describe('"https" module', () => { @@ -275,5 +305,33 @@ describe('HttpsProxyAgent', () => { assert.equal(sslServerUrl.hostname, body.host); } ); + + it('should work with `keepAlive: true`', async () => { + sslServer.on('request', (req, res) => { + res.end(JSON.stringify(req.headers)); + }); + + const agent = new HttpsProxyAgent(sslProxyUrl, { + keepAlive: true, + rejectUnauthorized: false, + }); + + try { + const res = await req(sslServerUrl, { agent, rejectUnauthorized: false }); + expect(res.headers.connection).toEqual('keep-alive'); + res.resume(); + const s1 = res.socket; + await once(s1, 'free'); + + const res2 = await req(sslServerUrl, { agent, rejectUnauthorized: false }); + expect(res2.headers.connection).toEqual('keep-alive'); + res2.resume(); + const s2 = res2.socket; + assert(s1 === s2); + await once(s2, 'free'); + } finally { + agent.destroy(); + } + }); }); }); diff --git a/packages/pac-proxy-agent/src/index.ts b/packages/pac-proxy-agent/src/index.ts index c0782967..0ece4c32 100644 --- a/packages/pac-proxy-agent/src/index.ts +++ b/packages/pac-proxy-agent/src/index.ts @@ -33,7 +33,8 @@ type Protocol = T extends `pac+${infer P}:${infer _}` ? P : never; -export type PacProxyAgentOptions = PacResolverOptions & +export type PacProxyAgentOptions = http.AgentOptions & + PacResolverOptions & GetUriOptions<`${Protocol}:`> & HttpProxyAgentOptions<''> & HttpsProxyAgentOptions<''> & @@ -70,7 +71,7 @@ export class PacProxyAgent extends Agent { resolverPromise?: Promise; constructor(uri: Uri | URL, opts?: PacProxyAgentOptions) { - super(); + super(opts); // Strip the "pac+" prefix const uriStr = typeof uri === 'string' ? uri : uri.href; diff --git a/packages/proxy-agent/src/index.ts b/packages/proxy-agent/src/index.ts index c0ad97a0..83735525 100644 --- a/packages/proxy-agent/src/index.ts +++ b/packages/proxy-agent/src/index.ts @@ -64,8 +64,8 @@ export class ProxyAgent extends Agent { connectOpts?: ProxyAgentOptions; constructor(opts?: ProxyAgentOptions) { - super(); - debug('Creating new ProxyAgent instance'); + super(opts); + debug('Creating new ProxyAgent instance: %o', opts); this.connectOpts = opts; } @@ -76,8 +76,6 @@ export class ProxyAgent extends Agent { const protocol = opts.secureEndpoint ? 'https:' : 'http:'; const host = req.getHeader('host'); const url = new URL(req.path, `${protocol}//${host}`).href; - debug('Request URL: %o', url); - const proxy = getProxyForUrl(url); if (!proxy) { @@ -85,6 +83,7 @@ export class ProxyAgent extends Agent { return opts.secureEndpoint ? https.globalAgent : http.globalAgent; } + debug('Request URL: %o', url); debug('Proxy URL: %o', proxy); // attempt to get a cached `http.Agent` instance first @@ -105,4 +104,11 @@ export class ProxyAgent extends Agent { return agent; } + + destroy(): void { + for (const agent of this.cache.values()) { + agent.destroy(); + } + super.destroy(); + } } diff --git a/packages/proxy-agent/test/test.ts b/packages/proxy-agent/test/test.ts index f477025b..8b490f57 100644 --- a/packages/proxy-agent/test/test.ts +++ b/packages/proxy-agent/test/test.ts @@ -8,6 +8,7 @@ import { ProxyServer, createProxy } from 'proxy'; import socks from 'socksv5'; import { listen } from 'async-listen'; import { ProxyAgent } from '../src'; +import { once } from 'events'; const sslOptions = { key: fs.readFileSync(__dirname + '/ssl-cert-snakeoil.key'), @@ -77,6 +78,8 @@ describe('ProxyAgent', () => { delete process.env.HTTP_PROXY; delete process.env.HTTPS_PROXY; delete process.env.NO_PROXY; + httpServer.removeAllListeners('request'); + httpsServer.removeAllListeners('request'); }); describe('"http" module', () => { @@ -135,6 +138,40 @@ describe('ProxyAgent', () => { const body = await json(res); assert.equal(httpServerUrl.host, body.host); }); + + it('should work with `keepAlive: true`', async () => { + httpServer.on('request', function (req, res) { + res.end(JSON.stringify(req.headers)); + }); + + process.env.HTTP_PROXY = httpsProxyServerUrl.href; + const agent = new ProxyAgent({ + keepAlive: true, + rejectUnauthorized: false, + }); + + try { + const res = await req(new URL('/test', httpServerUrl), { + agent, + }); + res.resume(); + expect(res.headers.connection).toEqual('keep-alive'); + const s1 = res.socket; + await once(s1, 'free'); + + const res2 = await req(new URL('/test', httpServerUrl), { + agent, + }); + res2.resume(); + expect(res2.headers.connection).toEqual('keep-alive'); + const s2 = res2.socket; + assert(s1 === s2); + + await once(s2, 'free'); + } finally { + agent.destroy(); + } + }); }); describe('"https" module', () => { diff --git a/packages/proxy/package.json b/packages/proxy/package.json index 5d25a2de..d4f395b8 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -9,7 +9,7 @@ ], "scripts": { "build": "tsc", - "test": "mocha --reporter spec", + "test": "jest --env node --verbose --bail", "lint": "eslint . --ext .ts", "pack": "node ../../scripts/pack.mjs", "prepublishOnly": "npm run build" @@ -42,9 +42,11 @@ "devDependencies": { "@types/args": "^5.0.0", "@types/debug": "^4.1.7", - "@types/mocha": "^5.2.7", + "@types/jest": "^29.5.1", "@types/node": "^14.18.43", - "mocha": "6", + "async-listen": "^2.1.0", + "jest": "^29.5.0", + "ts-jest": "^29.1.0", "tsconfig": "workspace:*", "typescript": "^5.0.4" }, diff --git a/packages/proxy/test/test.js b/packages/proxy/test/test.js deleted file mode 100644 index c2e0ae9e..00000000 --- a/packages/proxy/test/test.js +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Module dependencies. - */ - -const fs = require('fs'); -const net = require('net'); -const path = require('path'); -const http = require('http'); -const https = require('https'); -const assert = require('assert'); -const { createProxy } = require('../'); - -describe('proxy', () => { - var proxy; - var proxyPort; - - var server; - var serverPort; - - before(function (done) { - // setup proxy server - proxy = createProxy(http.createServer()); - proxy.listen(() => { - proxyPort = proxy.address().port; - done(); - }); - }); - - before(function (done) { - // setup target server - server = http.createServer(); - server.listen(() => { - serverPort = server.address().port; - done(); - }); - }); - - after(function (done) { - proxy.once('close', () => { - done(); - }); - proxy.close(); - }); - - after(function (done) { - server.once('close', () => { - done(); - }); - server.close(); - }); - - it('should proxy HTTP GET requests', function (done) { - var gotData = false; - var gotRequest = false; - var host = '127.0.0.1:' + serverPort; - server.once('request', function (req, res) { - gotRequest = true; - // ensure headers are being proxied - assert(req.headers['user-agent'] == 'curl/7.30.0'); - assert(req.headers.host == host); - assert(req.headers.accept == '*/*'); - res.end(); - }); - - var socket = net.connect({ port: proxyPort }); - socket.once('close', () => { - assert(gotData); - assert(gotRequest); - done(); - }); - socket.once('connect', () => { - socket.write( - 'GET http://' + - host + - '/ HTTP/1.1\r\n' + - 'User-Agent: curl/7.30.0\r\n' + - 'Host: ' + - host + - '\r\n' + - 'Accept: */*\r\n' + - 'Proxy-Connection: Keep-Alive\r\n' + - '\r\n' - ); - }); - socket.setEncoding('utf8'); - socket.once('data', function (data) { - assert(0 == data.indexOf('HTTP/1.1 200 OK\r\n')); - gotData = true; - socket.destroy(); - }); - }); - - it('should establish connection for CONNECT requests', function (done) { - var gotData = false; - var socket = net.connect({ port: proxyPort }); - socket.once('close', () => { - assert(gotData); - done(); - }); - socket.once('connect', () => { - var host = '127.0.0.1:' + serverPort; - socket.write( - 'CONNECT ' + - host + - ' HTTP/1.1\r\n' + - 'Host: ' + - host + - '\r\n' + - 'User-Agent: curl/7.30.0\r\n' + - 'Proxy-Connection: Keep-Alive\r\n' + - '\r\n' - ); - }); - socket.setEncoding('utf8'); - socket.once('data', function (data) { - assert( - 0 == data.indexOf('HTTP/1.1 200 Connection established\r\n') - ); - gotData = true; - socket.destroy(); - }); - }); - - describe('authentication', () => { - function clearAuth() { - delete proxy.authenticate; - } - - before(clearAuth); - after(clearAuth); - - it('should invoke the `server.authenticate()` function when set', function (done) { - var auth = 'Basic Zm9vOmJhcg=='; - var called = false; - proxy.authenticate = (req) => { - assert(auth === req.headers['proxy-authorization']); - socket.destroy(); - called = true; - }; - var socket = net.connect({ port: proxyPort }); - socket.once('close', () => { - assert(called); - done(); - }); - socket.once('connect', () => { - socket.write( - 'GET / HTTP/1.1\r\n' + - 'Host: foo.com\r\n' + - 'Proxy-Authorization: ' + - auth + - '\r\n' + - '\r\n' - ); - }); - }); - - it('should provide the HTTP client with a 407 response status code', function (done) { - // reject everything - proxy.authenticate = () => false; - var gotData = false; - var socket = net.connect({ port: proxyPort }); - socket.once('close', () => { - assert(gotData); - done(); - }); - socket.once('connect', () => { - socket.write('GET / HTTP/1.1\r\nHost: foo.com\r\n\r\n'); - }); - socket.setEncoding('utf8'); - socket.once('data', function (data) { - assert(0 == data.indexOf('HTTP/1.1 407')); - gotData = true; - socket.destroy(); - }); - }); - - it("should close the socket after a CONNECT request's 407 response status code", function (done) { - // reject everything - proxy.authenticate = () => false; - var gotData = false; - var socket = net.connect({ port: proxyPort }); - socket.once('close', () => { - assert(gotData); - done(); - }); - socket.once('connect', () => { - socket.write('CONNECT 127.0.0.1:80 HTTP/1.1\r\n\r\n'); - }); - socket.setEncoding('utf8'); - socket.once('data', function (data) { - assert(0 == data.indexOf('HTTP/1.1 407')); - gotData = true; - }); - }); - }); -}); diff --git a/packages/proxy/test/test.ts b/packages/proxy/test/test.ts new file mode 100644 index 00000000..0700ed8c --- /dev/null +++ b/packages/proxy/test/test.ts @@ -0,0 +1,206 @@ +import * as net from 'net'; +import * as http from 'http'; +import assert from 'assert'; +import { listen } from 'async-listen'; +import { createProxy, ProxyServer } from '../src/proxy'; +import { once } from 'events'; + +describe('proxy', () => { + let proxy: ProxyServer; + let proxyUrl: URL; + + let server: http.Server; + let serverUrl: URL; + + beforeAll(async () => { + // setup proxy server + proxy = createProxy(http.createServer()); + proxyUrl = (await listen(proxy)) as URL; + }); + + beforeAll(async () => { + // setup target server + server = http.createServer(); + serverUrl = (await listen(server)) as URL; + }); + + afterAll(() => { + proxy.close(); + server.close(); + }); + + beforeEach(() => { + server.removeAllListeners('request'); + }); + + it('should proxy HTTP GET requests (keep-alive)', async () => { + let requestCount = 0; + + server.on('request', (req, res) => { + requestCount++; + // ensure headers are being proxied + expect(req.headers['user-agent']).toEqual('curl/7.30.0'); + expect(req.headers.host).toEqual(serverUrl.host); + expect(req.headers.accept).toEqual('*/*'); + res.end(); + }); + + const socket = net.connect({ port: +proxyUrl.port }); + await once(socket, 'connect'); + socket.write( + 'GET http://' + + serverUrl.host + + '/ HTTP/1.1\r\n' + + 'User-Agent: curl/7.30.0\r\n' + + 'Host: ' + + serverUrl.host + + '\r\n' + + 'Accept: */*\r\n' + + 'Proxy-Connection: Keep-Alive\r\n' + + '\r\n' + ); + + socket.setEncoding('utf8'); + const [data] = await once(socket, 'data'); + assert(0 == data.indexOf('HTTP/1.1 200 OK\r\n')); + assert(requestCount); + + socket.write( + 'GET http://' + + serverUrl.host + + '/ HTTP/1.1\r\n' + + 'User-Agent: curl/7.30.0\r\n' + + 'Host: ' + + serverUrl.host + + '\r\n' + + 'Accept: */*\r\n' + + 'Proxy-Connection: Keep-Alive\r\n' + + '\r\n' + ); + const [data2] = await once(socket, 'data'); + assert(0 == data2.indexOf('HTTP/1.1 200 OK\r\n')); + + socket.destroy(); + }); + + it('should establish connection for CONNECT requests (keep-alive)', async () => { + let requestCount = 0; + + server.on('request', (req, res) => { + requestCount++; + // ensure headers are being proxied + expect(req.headers.host).toEqual(serverUrl.host); + expect(req.headers.foo).toEqual('bar'); + res.end(); + }); + + const socket = net.connect({ port: +proxyUrl.port }); + await once(socket, 'connect'); + + socket.write( + 'CONNECT ' + + serverUrl.host + + ' HTTP/1.1\r\n' + + 'Host: ' + + serverUrl.host + + '\r\n' + + 'User-Agent: curl/7.30.0\r\n' + + 'Proxy-Connection: Keep-Alive\r\n' + + '\r\n' + ); + + socket.setEncoding('utf8'); + const [data] = await once(socket, 'data'); + assert(0 == data.indexOf('HTTP/1.1 200 Connection established\r\n')); + expect(requestCount).toEqual(0); + + socket.write( + 'GET / HTTP/1.1\r\n' + + 'Host: ' + + serverUrl.host + + '\r\n' + + 'Connection: Keep-Alive\r\n' + + 'Foo: bar\r\n' + + '\r\n' + ); + + const [data2] = await once(socket, 'data'); + expect(data2.includes('Connection: keep-alive')).toEqual(true); + expect(requestCount).toEqual(1); + + socket.write( + 'GET / HTTP/1.1\r\n' + + 'Host: ' + + serverUrl.host + + '\r\n' + + 'Connection: Keep-Alive\r\n' + + 'Foo: bar\r\n' + + '\r\n' + ); + + const [data3] = await once(socket, 'data'); + expect(data3.includes('Connection: keep-alive')).toEqual(true); + expect(requestCount).toEqual(2); + + socket.destroy(); + }); + + describe('authentication', () => { + beforeAll(() => { + delete proxy.authenticate; + }); + + it('should invoke the `server.authenticate()` function when set', async () => { + const auth = 'Basic Zm9vOmJhcg=='; + + const authPromise = new Promise((resolve) => { + proxy.authenticate = (req) => { + assert(auth === req.headers['proxy-authorization']); + socket.destroy(); + resolve(); + return true; + }; + }); + + const socket = net.connect({ port: +proxyUrl.port }); + await once(socket, 'connect'); + socket.write( + 'GET / HTTP/1.1\r\n' + + 'Host: foo.com\r\n' + + 'Proxy-Authorization: ' + + auth + + '\r\n' + + '\r\n' + ); + + await authPromise; + }); + + it('should provide the HTTP client with a 407 response status code', async () => { + // reject everything + proxy.authenticate = () => false; + + const socket = net.connect({ port: +proxyUrl.port }); + await once(socket, 'connect'); + + socket.write('GET / HTTP/1.1\r\nHost: foo.com\r\n\r\n'); + + socket.setEncoding('utf8'); + const [data] = await once(socket, 'data'); + assert(0 == data.indexOf('HTTP/1.1 407')); + socket.destroy(); + }); + + it("should close the socket after a CONNECT request's 407 response status code", async () => { + // reject everything + proxy.authenticate = () => false; + + const socket = net.connect({ port: +proxyUrl.port }); + await once(socket, 'connect'); + socket.write('CONNECT 127.0.0.1:80 HTTP/1.1\r\n\r\n'); + socket.setEncoding('utf8'); + const [data] = await once(socket, 'data'); + assert(0 == data.indexOf('HTTP/1.1 407')); + }); + }); +}); diff --git a/packages/proxy/test/tsconfig.json b/packages/proxy/test/tsconfig.json new file mode 100644 index 00000000..a79e2e63 --- /dev/null +++ b/packages/proxy/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["test.ts"] +} diff --git a/packages/socks-proxy-agent/src/index.ts b/packages/socks-proxy-agent/src/index.ts index 9d86ee60..22b00768 100644 --- a/packages/socks-proxy-agent/src/index.ts +++ b/packages/socks-proxy-agent/src/index.ts @@ -6,14 +6,6 @@ import * as net from 'net'; import * as tls from 'tls'; import * as http from 'http'; -interface BaseSocksProxyAgentOptions { - tls?: tls.ConnectionOptions | null; -} - -interface SocksProxyAgentOptionsExtra { - timeout?: number; -} - const debug = createDebug('socks-proxy-agent'); function parseSocksURL(url: URL): { lookup: boolean; proxy: SocksProxy } { @@ -78,9 +70,12 @@ function parseSocksURL(url: URL): { lookup: boolean; proxy: SocksProxy } { return { lookup, proxy }; } -export interface SocksProxyAgentOptions - extends BaseSocksProxyAgentOptions, - Partial> {} +export type SocksProxyAgentOptions = Omit< + SocksProxy, + // These come from the parsed URL + 'ipaddress' | 'host' | 'port' | 'type' | 'userId' | 'password' +> & + http.AgentOptions; export class SocksProxyAgent extends Agent { static protocols = [ @@ -93,19 +88,16 @@ export class SocksProxyAgent extends Agent { private readonly shouldLookup: boolean; private readonly proxy: SocksProxy; - private readonly tlsConnectionOptions: tls.ConnectionOptions; public timeout: number | null; - constructor(uri: string | URL, opts?: SocksProxyAgentOptionsExtra) { - super(); + constructor(uri: string | URL, opts?: SocksProxyAgentOptions) { + super(opts); const url = typeof uri === 'string' ? new URL(uri) : uri; const { proxy, lookup } = parseSocksURL(url); this.shouldLookup = lookup; this.proxy = proxy; - //this.tlsConnectionOptions = proxyOptions.tls != null ? proxyOptions.tls : {} - this.tlsConnectionOptions = {}; this.timeout = opts?.timeout ?? null; } @@ -119,13 +111,13 @@ export class SocksProxyAgent extends Agent { ): Promise { const { shouldLookup, proxy, timeout } = this; - let { host } = opts; - const { port, lookup: lookupFn = dns.lookup } = opts; - - if (!host) { + if (!opts.host) { throw new Error('No `host` defined!'); } + let { host } = opts; + const { port, lookup: lookupFn = dns.lookup } = opts; + if (shouldLookup) { // Client-side DNS resolution for "4" and "5" socks proxy versions. host = await new Promise((resolve, reject) => { @@ -170,13 +162,11 @@ export class SocksProxyAgent extends Agent { // The proxy is connecting to a TLS server, so upgrade // this socket connection to a TLS connection. debug('Upgrading socket connection to TLS'); - const servername = opts.servername ?? opts.host; - + const servername = opts.servername || opts.host; const tlsSocket = tls.connect({ ...omit(opts, 'host', 'path', 'port'), socket, - servername, - ...this.tlsConnectionOptions, + servername: net.isIP(servername) ? undefined : servername, }); tlsSocket.once('error', (error) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9d365de..075e4350 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -360,15 +360,21 @@ importers: '@types/debug': specifier: ^4.1.7 version: 4.1.7 - '@types/mocha': - specifier: ^5.2.7 - version: 5.2.7 + '@types/jest': + specifier: ^29.5.1 + version: 29.5.1 '@types/node': specifier: ^14.18.43 version: 14.18.43 - mocha: - specifier: '6' - version: 6.2.3 + async-listen: + specifier: ^2.1.0 + version: 2.1.0 + jest: + specifier: ^29.5.0 + version: 29.5.0(@types/node@14.18.43) + ts-jest: + specifier: ^29.1.0 + version: 29.1.0(@babel/core@7.21.4)(jest@29.5.0)(typescript@5.0.4) tsconfig: specifier: workspace:* version: link:../tsconfig @@ -1558,10 +1564,6 @@ packages: resolution: {integrity: sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==} dev: true - /@types/mocha@5.2.7: - resolution: {integrity: sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ==} - dev: true - /@types/ms@0.7.31: resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==} dev: true