From 31ea8154053bfefee418b82ac856d448003d56c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 14:21:52 +0000 Subject: [PATCH 1/2] test(e2e): regenerate expired TLS certificate with 100-year validity The previous self-signed cert expired 2018-11-10. Node tolerated it because the tests pass `rejectUnauthorized: false`, but Bun rejects expired certs strictly on the WebSocket path, failing four e2e tests with "certificate has expired". New cert: sha256, SAN covers localhost, localhost-test, 127.0.0.1 and ::1, valid until 2126. --- test/e2e/ssl.crt | 40 +++++++++++++++++------------------ test/e2e/ssl.key | 55 ++++++++++++++++++++++++------------------------ 2 files changed, 48 insertions(+), 47 deletions(-) diff --git a/test/e2e/ssl.crt b/test/e2e/ssl.crt index 00bad927..99bc4ca0 100644 --- a/test/e2e/ssl.crt +++ b/test/e2e/ssl.crt @@ -1,22 +1,22 @@ -----BEGIN CERTIFICATE----- -MIIDtTCCAp2gAwIBAgIJAOyaEf+jBkG4MA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV -BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX -aWRnaXRzIFB0eSBMdGQwHhcNMTcxMTEwMjAzNDM0WhcNMTgxMTEwMjAzNDM0WjBF -MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 -ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB -CgKCAQEAveAoPujCQ7RN2R09/Gp8cby6DyVOyob9VdiSJp8tTjWL0YmuGMdDa84n -BbUbig8z2J5zvnce8/kwIGEIpe9Aho4pNHe9+q+BaLNWdFdazDO2rVjIuDNvylqB -UZ3MeVY7uhVPIc7i4I8nh48dLIwCoo6bZuAKWjGNbOZ34iuvocixeLLjD9FPrfyS -miFNvYYBIIE1cuG6v4c/6D58TNkon2dIWk4WdT8exRggSSkcn0gkfj0V7c4pbJsh -xe2EihLEvT5CIL2oucQw0Nq1kzRBl9nIglrd7DO9CAYPlx3Kx3WoHG4MdibfbHbI -WcaWbQcNTKOXMQa5bEsijzEd3uzxrQIDAQABo4GnMIGkMB0GA1UdDgQWBBR/xkww -83cpqsT61bGnym/mFdbn9TB1BgNVHSMEbjBsgBR/xkww83cpqsT61bGnym/mFdbn -9aFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV -BAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAOyaEf+jBkG4MAwGA1UdEwQF -MAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAJvr1vJO373jCTztVWqs1DxVUpC8TMAO -zrv4Ry+1xcxowDkdyTNXhwfqshbrTEmfhl92zjNy4ZrYN/KN8kM+jg/fHbw5KNSd -uNH2a74BuXVQR/fscFPsqmIWlsyrSCKpRUi0dLKo67ZrBcnUMYwBnxdQxu0hoB81 -B5ZDLptogoc3YN8+XmjqghKEx22hC1+RalQ4pI3n7ru73NLukLJb2c4kjK9AsZq3 -44Q5+RajPtFha+mTlRyh9ZCMWgjzqESfvGKHoIq2gcLGWN2FuqKS9SIU8TfdUoh4 -N7ABI4y4lktKuq/5AcHZXuXwLiuCG3rOGeb6zgUV0jXb79C0unDWbTs= +MIIDuTCCAqGgAwIBAgIUb9W9BeBHuz1x8Xdwxn1XnyU8X/QwDQYJKoZIhvcNAQEL +BQAwSzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRlc3QxGTAXBgNVBAoMEHByb3h5 +LWNoYWluIHRlc3QxEjAQBgNVBAMMCWxvY2FsaG9zdDAgFw0yNjA1MTIyMDQzMzNa +GA8yMTI2MDQxODIwNDMzM1owSzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRlc3Qx +GTAXBgNVBAoMEHByb3h5LWNoYWluIHRlc3QxEjAQBgNVBAMMCWxvY2FsaG9zdDCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALpi9p3osGEeS3plKCLzAXUA +WxVo6X1AO/GmQEw5IG8y15GxBcyMG6BueAaV7/aMWWCHBfsEXkAKr6qGPhgqHuoP +FFcYUvr52OKbfAQpN8XQ56kadKQ60hTWPqYTYulNh/sENDQkx2SWnFsTs/LVVtUX +ltx7gwphbdLeCe7Yx42/fk4NI3Esg9okpvomtNSVuDTiwi/YG1WWLeCq5FIpZ3of +2NClo86jWYctR/1KdyjpatCO7Bm2A1NTWblqMgxh4DDwUoIgxTaYgspARd0zT0Z/ +Q7OkynbNFfOz+rZpsXRWJ/TLyYUMoZ95MVeD1bVViPfjdU0G3A/lpjuoMyS0JbUC +AwEAAaOBkjCBjzAdBgNVHQ4EFgQUloEOqBDUaVzabtl09fT/RcdIUdcwHwYDVR0j +BBgwFoAUloEOqBDUaVzabtl09fT/RcdIUdcwDwYDVR0TAQH/BAUwAwEB/zA8BgNV +HREENTAzgglsb2NhbGhvc3SCDmxvY2FsaG9zdC10ZXN0hwR/AAABhxAAAAAAAAAA +AAAAAAAAAAABMA0GCSqGSIb3DQEBCwUAA4IBAQAzTtlx5FL8hGM5SN593C8tX9PO +K4LpbzRZ0halkyC1yDAoSpibtRJhoAIa9zLyLzApjRNI7os5qRIetrZ13eJNluPv +XJl0dDN+YuDz3adz1uOtWRkt96Oipf3ZImubDK0P5lEf6RA4SarOqAbk6ar84Owy +7vGMGt0XLgQ+27RhKqnXeZDmUXBQdmWdDDqxpMCtKMBDY7NO08doa7BZeUevq3/C +bnWCYhbAj0DiEWbMBXq3k9tuFGdVnl2ccxfwzx7UGORaQgoNePq4eyS55QrY6mho +4YbrJsxruudm4cE2YiiNrxJzAdDZyRYa+hBO70y9lhfyVaZvdN0NMlvOi3OK -----END CERTIFICATE----- diff --git a/test/e2e/ssl.key b/test/e2e/ssl.key index 9665c202..e825357b 100644 --- a/test/e2e/ssl.key +++ b/test/e2e/ssl.key @@ -1,27 +1,28 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEowIBAAKCAQEAveAoPujCQ7RN2R09/Gp8cby6DyVOyob9VdiSJp8tTjWL0Ymu -GMdDa84nBbUbig8z2J5zvnce8/kwIGEIpe9Aho4pNHe9+q+BaLNWdFdazDO2rVjI -uDNvylqBUZ3MeVY7uhVPIc7i4I8nh48dLIwCoo6bZuAKWjGNbOZ34iuvocixeLLj -D9FPrfySmiFNvYYBIIE1cuG6v4c/6D58TNkon2dIWk4WdT8exRggSSkcn0gkfj0V -7c4pbJshxe2EihLEvT5CIL2oucQw0Nq1kzRBl9nIglrd7DO9CAYPlx3Kx3WoHG4M -dibfbHbIWcaWbQcNTKOXMQa5bEsijzEd3uzxrQIDAQABAoIBAQCp2ZgG1m3Y5LRy -0I6/en5BvAJwQ/5cey6pmWb7t45ulMWzNkcPkUiFak9L8rtk376QOwXszmBY/IMJ -o+N5lDETbJ39elPuqQrJHwvqXK4zVttF69L5u8F3sUhXOyJLNFGPXzp/UrNvD3/b -6rC9Ra2hvpHTD/0Su5r4XJ3HKy8cN4ErQprmEJhKDYrP/Lp2uKDPwoxXiYcPOPKC -CbgPLRrK+40GbCXtZVQgX0+nrJ/0syryNaA9wb1wHXfdWLyhvM2BGHz9gXtv2z1L -3VvvbKpO2pygnLgUjLeJbk0/UI5Orz8MEAzGiq4wwiGqQZaGx/1C/WBlvnqnwuA5 -6vQQC7Q9AoGBAOoQZbir+W+ihkwIkTGb6pHnaPTAuslPSfR6RJbxGlv78wJ260KE -foRKVdb06gQWwZwHniKA0GZeKBFNxSSdkyt8yZY84/w9KLFitX5HaBFoggroOWx+ -JCMuPDDnnlHm9REUzR2Zsl3KedNeV92JUDjx+ObUbexeK8wd0mLtqcO7AoGBAM+r -mkJ4OrpTkPqVxkOim+LjMe/7EqX14QkPQrg3KzZmhyi7THOB5JMH+0/Bygrs1VP5 -OSEzWbPnjQ0GWoFEIw/iJbhvikhJPi4PXMrXIhreXcX7GezGp3nKHNf5vNpKAk4Z -fPlCmHEBtcBHPygDPEODkTPz7B74QX6NX4ZrWCW3AoGAMg7Psm8VKYrYreonIzT1 -Nb8H81BEokkSx/ZeNOnbeVCo6B4GsnMjm6dKNG6snbNANN5sM3TZHQuGBi1bvDj3 -AJXvhvH+0DNEQKubpSYgW5i+NxbzMQDJObzpoovmkB2Uy9JnC62TN/vVkh7bK8Xy -Ijudv8Auwh5hv4WhOQcbB4ECgYAqrG2PeRtATIm/JGXQYiq8TclmMeacGdF7RhqE -tjl3/UuK0CoeljN9DyfSNNUqt44CqnTV4LJvKIawhXy1kWXPDr6HjswQnJRdbKS5 -vclxUf5c/4NNR2kEusaAjv4CsTCWEeC/a7LdjedmMn3E4B1TFkcRMO91UbhLpAtc -GNTNMwKBgH954dHwNWbAXGJlqvP75MIuPdFNbi0TVKR8V9PbFg9eOVvWaeGGUr4I -yUoDPTndfogpiT/PuXBy4IQ+BYNza0fTVcJzTD5vOgoeRYUDdL5SYAlnIhEVhw2U -frBtb6JYt7jgP7HXyLG75+p+PVujxt20smxUKyLCfIqTNXeyIosW ------END RSA PRIVATE KEY----- +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC6Yvad6LBhHkt6 +ZSgi8wF1AFsVaOl9QDvxpkBMOSBvMteRsQXMjBugbngGle/2jFlghwX7BF5ACq+q +hj4YKh7qDxRXGFL6+djim3wEKTfF0OepGnSkOtIU1j6mE2LpTYf7BDQ0JMdklpxb +E7Py1VbVF5bce4MKYW3S3gnu2MeNv35ODSNxLIPaJKb6JrTUlbg04sIv2BtVli3g +quRSKWd6H9jQpaPOo1mHLUf9Snco6WrQjuwZtgNTU1m5ajIMYeAw8FKCIMU2mILK +QEXdM09Gf0OzpMp2zRXzs/q2abF0Vif0y8mFDKGfeTFXg9W1VYj343VNBtwP5aY7 +qDMktCW1AgMBAAECggEASXwEDGz1wUyO6JIiWWkijW0W/CFxUQL2v0bTOdoaCg1r +BVNcXHFu4Pk81CfgRH3ALTj+6CEoQaqU6K/lomRU7OYGGHKMIMxJsMopgoB3r0O2 +WisGistwEWwIpjSlfiMlthdJt7c6b9ObkKL7gwWOpxQWVBZUK08PXCBTnToVrzIA +njrHcY+BKagCCT29QS3VggrkVQ9vFw29Nopc+vWtvTW6dAwOYtcdjPh0TuUWAoUm +B/YDOdCxinTZL/iDmSS2AorTXyjwyni+9xB/Kc1wWdyJD25sUv4/amlLd/ctrNe0 +ThCQ36cHrGkm1/YQniOCnfXzt/jmSSmeKyGK5oRBPwKBgQDkv4Yc7Er+ikeeN0Qu +XaGYU5x2IHn3gRH+BXs/hV03r5LGmG4BwDsjN9xLZl+wEsCj2agfdpZ6MBL/tir5 +Cv38wSO8BNPr/5tm/ReNvtq6xVYrSfggPqhxOgVCx+4CFHWT/nsGaaORT/U7tI/k +DSSziRvzFy7qqNVluJGGrojWOwKBgQDQl3mg6EaEgL3Ij9e3S6k6+9QF4mETe4l6 +3pp/f9Av+wZYPpU6C9sXzFiycl+lFRFf7aRzXFQbe5gtTRE0qMrh4jcB2/rgrl29 +LglxfRKOUbQro5XKXFcW4pqpWtKFCKVpp1Ulm3LRzvmJXTaqpD3EA+oLiqmCDKRQ +dL6+GMIEzwKBgBg9l5e/Dp90xewlTStgrrw2uBDoliQ9YEu8BviPSHabO4GiK54x +4dJ0m/q9iYxeIF38tc1HwuCF8a15f4pOuOWtDf1hwZdzyeMbFQDnZcR/HweNWicI +nM8K5/3QtA8yXddmE2F1lmjSwVknZEw1fFsuP3D+VvF1HNAfxAQywLt7AoGBAI+4 +Pnpqb+Pt3FVrHoNRY0mbp4tiXeaRkLkS1TqR8vyMJeP5QtJaxttP1bEl8taIfI8u +6sb9T4ocD07vMbKpthgKyEHEssfJ/BZTuPfz6CwdCVmj/ZoPI4ZGHbAgPrqgqW/x +dk5SG7uJsw89JWRPg6sh00megYp0cWZp+d56qnurAoGAD9H2P1U57mOamCXQ7CXp +V7dTz0g0CtL27vv0oyuALO7KZkQ1x8VPH/SyeHsrhAILTyHPBNQlQB+0yqhiu4bM +vSSf53h6krtPvR/CyBhHOOkZY3JaMHoHeEpl09ItkEgzLDlQvRE+RDWIQxC8wfK3 +X1EXRrG8H6/refNFZizb/5M= +-----END PRIVATE KEY----- From e76b96b0a51d8f63065fc400b3710cb58aad4943 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 14:22:06 +0000 Subject: [PATCH 2/2] feat(bun): support running under Bun (raw-socket CONNECT chaining) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes that make proxy-chain work as a library under Bun 1.3, inspired by the approach in #649: 1. `chain.ts` no longer tunnels through `http.request().on('connect')`. Bun implements `http.request` on top of `fetch()`, which (a) rejects RFC 7230 authority-form CONNECT paths like `:443` with "fetch() URL is invalid" and (b) swallows 407/590-class upstream responses. Instead we open a raw `net`/`tls` socket, write the CONNECT request ourselves, parse the response headers, and `unshift` any remaining bytes back as tunnel payload. The synthetic response object keeps `rawHeaders` so `tunnelConnectResponded`/`tunnelConnectFailed` subscribers see the same shape as before. TLS options configured on `httpsAgent` (custom `ca`, client certs) are honored by re-using the agent's stored constructor options for the direct connection. 2. `Server.onRequest` routes `CONNECT` requests to `onConnect`: Bun delivers CONNECT through the generic 'request' event for some client shapes instead of the dedicated 'connect' event. 3. `Server.getConnectionStats` returns `undefined` under Bun. Bun does not populate `socket.bytesRead`/`bytesWritten` on http sockets, so the stats would silently read as zero — better no stats than wrong stats. (`connectionClosed` events consequently carry `stats: undefined` on Bun.) Node behavior is unchanged: full e2e suite produces an identical pass/fail set to master baseline (2012 passing, same 174 pre-existing environment failures) on Node 22. --- src/chain.ts | 273 ++++++++++++++++++++++++++++---------------------- src/server.ts | 16 +++ 2 files changed, 172 insertions(+), 117 deletions(-) diff --git a/src/chain.ts b/src/chain.ts index 5e02bfd0..eaad892d 100644 --- a/src/chain.ts +++ b/src/chain.ts @@ -1,8 +1,10 @@ -import type { Buffer } from 'node:buffer'; +import { Buffer } from 'node:buffer'; import type dns from 'node:dns'; import type { EventEmitter } from 'node:events'; -import http from 'node:http'; -import https from 'node:https'; +import type http from 'node:http'; +import type https from 'node:https'; +import net from 'node:net'; +import tls from 'node:tls'; import type { URL } from 'node:url'; import type { Socket } from './socket.js'; @@ -11,15 +13,6 @@ import type { SocketWithPreviousStats } from './utils/count_target_bytes.js'; import { countTargetBytes } from './utils/count_target_bytes.js'; import { getBasicAuthorizationHeader } from './utils/get_basic.js'; -interface Options { - method: string; - headers: Record; - path?: string; - localAddress?: string; - family?: number; - lookup?: typeof dns['lookup']; -} - export interface HandlerOpts { upstreamProxyUrlParsed: URL; ignoreUpstreamProxyCertificate: boolean; @@ -44,6 +37,13 @@ interface ChainOpts { * Passes the traffic to upstream HTTP proxy server. * Client -> Apify -> Upstream -> Web * Client <- Apify <- Upstream <- Web + * + * Uses raw TCP/TLS sockets to establish the upstream CONNECT tunnel rather + * than `http.request().on('connect', ...)`. The latter is implemented on top + * of `fetch()` in Bun 1.3 and (a) rejects non-URL CONNECT paths like `:443` + * with "fetch() URL is invalid", (b) silently swallows the 407/590-class + * upstream responses tests rely on. Speaking CONNECT directly over a socket + * sidesteps both quirks without changing the behaviour on Node. */ export const chain = ( { @@ -67,151 +67,190 @@ export const chain = ( } const { proxyChainId } = sourceSocket; - const { upstreamProxyUrlParsed: proxy, customTag } = handlerOpts; - const options: Options = { - method: 'CONNECT', - path: request.url, - headers: { - host: request.url!, - }, + const isHttps = proxy.protocol === 'https:'; + const proxyHost = proxy.hostname; + const proxyPort = Number(proxy.port) || (isHttps ? 443 : 80); + + let connectRequest = `CONNECT ${request.url} HTTP/1.1\r\nHost: ${request.url}\r\n`; + if (proxy.username || proxy.password) { + connectRequest += `Proxy-Authorization: ${getBasicAuthorizationHeader(proxy)}\r\n`; + } + connectRequest += '\r\n'; + + const socketOptions: net.TcpNetConnectOpts = { + host: proxyHost, + port: proxyPort, localAddress: handlerOpts.localAddress, - family: handlerOpts.ipFamily, + family: handlerOpts.ipFamily as 4 | 6 | undefined, lookup: handlerOpts.dnsLookup, }; - if (proxy.username || proxy.password) { - options.headers['proxy-authorization'] = getBasicAuthorizationHeader(proxy); - } + let targetSocket: net.Socket; - const client = proxy.protocol === 'https:' - ? https.request(proxy.origin, { - ...options, - rejectUnauthorized: !handlerOpts.ignoreUpstreamProxyCertificate, - agent: handlerOpts.httpsAgent, - }) - : http.request(proxy.origin, { - ...options, - agent: handlerOpts.httpAgent, - }); - - client.once('socket', (targetSocket: SocketWithPreviousStats) => { - // Socket can be re-used by multiple requests. - // That's why we need to track the previous stats. - targetSocket.previousBytesRead = targetSocket.bytesRead; - targetSocket.previousBytesWritten = targetSocket.bytesWritten; - countTargetBytes(sourceSocket, targetSocket); - }); + const onPreConnectError = (error: NodeJS.ErrnoException): void => { + server.log(proxyChainId, `Failed to connect to upstream proxy: ${error.stack}`); - client.on('connect', (response, targetSocket, clientHead) => { - if (sourceSocket.readyState !== 'open') { - // Sanity check, should never reach. - targetSocket.destroy(); - return; + if (sourceSocket.readyState === 'open') { + if (isPlain) { + sourceSocket.end(); + } else { + const statusCode = errorCodeToStatusCode[error.code!] ?? badGatewayStatusCodes.GENERIC_ERROR; + const response = createCustomStatusHttpResponse(statusCode, error.code ?? 'Upstream Closed Early'); + sourceSocket.end(response); + } } + }; - targetSocket.on('error', (error) => { - server.log(proxyChainId, `Chain Destination Socket Error: ${error.stack}`); + const onProxyConnected = (): void => { + targetSocket.write(connectRequest); - sourceSocket.destroy(); - }); + let responseBuffer = Buffer.alloc(0); - sourceSocket.on('error', (error) => { - server.log(proxyChainId, `Chain Source Socket Error: ${error.stack}`); + const onData = (chunk: Buffer): void => { + responseBuffer = Buffer.concat([responseBuffer, chunk]); - targetSocket.destroy(); - }); + const headerEnd = responseBuffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) return; - if (response.statusCode !== 200) { - server.log(proxyChainId, `Failed to authenticate upstream proxy: ${response.statusCode}`); + targetSocket.removeListener('data', onData); - if (isPlain) { - sourceSocket.end(); - } else { - const { statusCode } = response; - const status = statusCode === 401 || statusCode === 407 - ? badGatewayStatusCodes.AUTH_FAILED - : badGatewayStatusCodes.NON_200; + const headerStr = responseBuffer.subarray(0, headerEnd).toString(); + const remaining = responseBuffer.subarray(headerEnd + 4); + + const statusMatch = headerStr.match(/^HTTP\/\d+(?:\.\d+)? (\d+)(?: (.*))?/); + const statusCode = statusMatch ? parseInt(statusMatch[1], 10) : 0; + const statusMessage = statusMatch ? (statusMatch[2] || '') : ''; - sourceSocket.end(createCustomStatusHttpResponse(status, `UPSTREAM${statusCode}`)); + const headers: Record = {}; + const rawHeaders: string[] = []; + for (const line of headerStr.split('\r\n').slice(1)) { + if (!line) continue; + const colonIdx = line.indexOf(':'); + if (colonIdx > 0) { + const name = line.slice(0, colonIdx).trim(); + const value = line.slice(colonIdx + 1).trim(); + headers[name.toLowerCase()] = value; + rawHeaders.push(name, value); + } } - targetSocket.end(); + const response = { statusCode, statusMessage, headers, rawHeaders } as unknown as http.IncomingMessage; + + if (sourceSocket.readyState !== 'open') { + targetSocket.destroy(); + return; + } + + targetSocket.removeListener('error', onPreConnectError); + + targetSocket.on('error', (error) => { + server.log(proxyChainId, `Chain Destination Socket Error: ${error.stack}`); + sourceSocket.destroy(); + }); + + sourceSocket.on('error', (error) => { + server.log(proxyChainId, `Chain Source Socket Error: ${error.stack}`); + targetSocket.destroy(); + }); + + if (statusCode !== 200) { + server.log(proxyChainId, `Failed to authenticate upstream proxy: ${statusCode}`); - server.emit('tunnelConnectFailed', { + if (isPlain) { + sourceSocket.end(); + } else { + const status = statusCode === 401 || statusCode === 407 + ? badGatewayStatusCodes.AUTH_FAILED + : badGatewayStatusCodes.NON_200; + + sourceSocket.end(createCustomStatusHttpResponse(status, `UPSTREAM${statusCode}`)); + } + + targetSocket.end(); + + server.emit('tunnelConnectFailed', { + proxyChainId, + response, + customTag, + socket: targetSocket, + head: remaining, + }); + + return; + } + + if (remaining.length > 0) { + // See comment above re: pre-response CONNECT payload + targetSocket.unshift(remaining); + } + + server.emit('tunnelConnectResponded', { proxyChainId, response, customTag, socket: targetSocket, - head: clientHead, + head: remaining, }); - return; - } + sourceSocket.write(isPlain ? '' : `HTTP/1.1 200 Connection Established\r\n\r\n`); - if (clientHead.length > 0) { - // See comment above - targetSocket.unshift(clientHead); - } + sourceSocket.pipe(targetSocket); + targetSocket.pipe(sourceSocket); - server.emit('tunnelConnectResponded', { - proxyChainId, - response, - customTag, - socket: targetSocket, - head: clientHead, - }); + // Once target socket closes forcibly, the source socket gets paused. + // We need to enable flowing, otherwise the socket would remain open indefinitely. + // Nothing would consume the data, we just want to close the socket. + targetSocket.on('close', () => { + sourceSocket.resume(); - sourceSocket.write(isPlain ? '' : `HTTP/1.1 200 Connection Established\r\n\r\n`); + if (sourceSocket.writable) { + sourceSocket.end(); + } + }); - sourceSocket.pipe(targetSocket); - targetSocket.pipe(sourceSocket); + // Same here. + sourceSocket.on('close', () => { + targetSocket.resume(); - // Once target socket closes forcibly, the source socket gets paused. - // We need to enable flowing, otherwise the socket would remain open indefinitely. - // Nothing would consume the data, we just want to close the socket. - targetSocket.on('close', () => { - sourceSocket.resume(); + if (targetSocket.writable) { + targetSocket.end(); + } + }); + }; - if (sourceSocket.writable) { - sourceSocket.end(); - } - }); + targetSocket.on('data', onData); + }; - // Same here. - sourceSocket.on('close', () => { - targetSocket.resume(); + if (isHttps) { + // We connect directly instead of going through `https.request` with an + // agent, but users may have configured TLS settings (custom `ca`, + // client certs, ...) on `httpsAgent`. Honor those by re-using the + // agent's stored constructor options for this connection. + const httpsAgentOptions = handlerOpts.httpsAgent?.options as tls.ConnectionOptions | undefined; - if (targetSocket.writable) { - targetSocket.end(); - } - }); - }); + targetSocket = tls.connect({ + ...httpsAgentOptions, + ...socketOptions, + rejectUnauthorized: !handlerOpts.ignoreUpstreamProxyCertificate, + }, onProxyConnected); + } else { + targetSocket = net.createConnection(socketOptions, onProxyConnected); + } - client.on('error', (error: NodeJS.ErrnoException) => { - server.log(proxyChainId, `Failed to connect to upstream proxy: ${error.stack}`); + (targetSocket as SocketWithPreviousStats).previousBytesRead = 0; + (targetSocket as SocketWithPreviousStats).previousBytesWritten = 0; + countTargetBytes(sourceSocket, targetSocket); - // The end socket may get connected after the client to proxy one gets disconnected. - if (sourceSocket.readyState === 'open') { - if (isPlain) { - sourceSocket.end(); - } else { - const statusCode = errorCodeToStatusCode[error.code!] ?? badGatewayStatusCodes.GENERIC_ERROR; - const response = createCustomStatusHttpResponse(statusCode, error.code ?? 'Upstream Closed Early'); - sourceSocket.end(response); - } - } - }); + targetSocket.on('error', onPreConnectError); sourceSocket.on('error', () => { - client.destroy(); + targetSocket.destroy(); }); // In case the client ends the socket too early sourceSocket.on('close', () => { - client.destroy(); + targetSocket.destroy(); }); - - client.end(); }; diff --git a/src/server.ts b/src/server.ts index 918eb886..7a61fc29 100644 --- a/src/server.ts +++ b/src/server.ts @@ -361,6 +361,15 @@ export class Server extends EventEmitter { * Handles normal HTTP request by forwarding it to target host or the upstream proxy. */ async onRequest(request: http.IncomingMessage, response: http.ServerResponse): Promise { + // Some runtimes (Bun 1.3) deliver HTTP CONNECT requests through the + // generic 'request' event rather than the dedicated 'connect' event. + // Route them through onConnect explicitly so CONNECT tunnelling keeps + // working on those runtimes. + if (request.method === 'CONNECT') { + await this.onConnect(request, request.socket as Socket, Buffer.alloc(0)); + return; + } + try { const handlerOpts = await this.prepareRequestHandling(request); handlerOpts.srcResponse = response; @@ -698,8 +707,15 @@ export class Server extends EventEmitter { /** * Gets data transfer statistics of a specific proxy connection. + * + * Returns `undefined` when the connection does not exist, and also when + * running under Bun: Bun (as of 1.3) does not populate + * `socket.bytesRead` / `socket.bytesWritten` on http sockets, so the + * numbers would silently read as zero. Better no stats than wrong stats. */ getConnectionStats(connectionId: number): ConnectionStats | undefined { + if (process.versions.bun) return undefined; + const socket = this.connections.get(connectionId); if (!socket) return undefined;