UNPKG

@revoloo/cypress6

Version:

Cypress.io end to end testing tool

365 lines (278 loc) 10.9 kB
import debugModule from 'debug' import http from 'http' import https from 'https' import _ from 'lodash' import net from 'net' import { getProxyForUrl } from 'proxy-from-env' import url from 'url' import { createRetryingSocket, getAddress } from './connect' const debug = debugModule('cypress:network:agent') const CRLF = '\r\n' const statusCodeRe = /^HTTP\/1.[01] (\d*)/ type WithProxyOpts<RequestOptions> = RequestOptions & { proxy: string shouldRetry?: boolean } type RequestOptionsWithProxy = WithProxyOpts<http.RequestOptions> type HttpsRequestOptions = https.RequestOptions & { minVersion?: 'TLSv1' } type HttpsRequestOptionsWithProxy = WithProxyOpts<HttpsRequestOptions> type FamilyCache = { [host: string]: 4 | 6 } export function buildConnectReqHead (hostname: string, port: string, proxy: url.Url) { const connectReq = [`CONNECT ${hostname}:${port} HTTP/1.1`] connectReq.push(`Host: ${hostname}:${port}`) if (proxy.auth) { connectReq.push(`Proxy-Authorization: basic ${Buffer.from(proxy.auth).toString('base64')}`) } return connectReq.join(CRLF) + _.repeat(CRLF, 2) } interface CreateProxySockOpts { proxy: url.Url shouldRetry?: boolean } type CreateProxySockCb = ( (err: undefined, result: net.Socket, triggerRetry: (err: Error) => void) => void ) & ( (err: Error) => void ) export const createProxySock = (opts: CreateProxySockOpts, cb: CreateProxySockCb) => { if (opts.proxy.protocol !== 'https:' && opts.proxy.protocol !== 'http:') { return cb(new Error(`Unsupported proxy protocol: ${opts.proxy.protocol}`)) } const isHttps = opts.proxy.protocol === 'https:' const port = opts.proxy.port || (isHttps ? 443 : 80) let connectOpts: any = { port: Number(port), host: opts.proxy.hostname, useTls: isHttps, } if (!opts.shouldRetry) { connectOpts.getDelayMsForRetry = () => undefined } createRetryingSocket(connectOpts, (err, sock, triggerRetry) => { if (err) { return cb(err) } cb(undefined, <net.Socket>sock, <CreateProxySockCb>triggerRetry) }) } export const isRequestHttps = (options: http.RequestOptions) => { // WSS connections will not have an href, but you can tell protocol from the defaultAgent return _.get(options, '_defaultAgent.protocol') === 'https:' || (options.href || '').slice(0, 6) === 'https' } export const isResponseStatusCode200 = (head: string) => { // read status code from proxy's response const matches = head.match(statusCodeRe) return _.get(matches, 1) === '200' } export const regenerateRequestHead = (req: http.ClientRequest) => { delete req._header req._implicitHeader() if (req.output && req.output.length > 0) { // the _header has already been queued to be written to the socket const first = req.output[0] const endOfHeaders = first.indexOf(_.repeat(CRLF, 2)) + 4 req.output[0] = req._header + first.substring(endOfHeaders) } } const getFirstWorkingFamily = ( { port, host }: http.RequestOptions, familyCache: FamilyCache, cb: Function, ) => { // this is a workaround for localhost (and potentially others) having invalid // A records but valid AAAA records. here, we just cache the family of the first // returned A/AAAA record for a host that we can establish a connection to. // https://github.com/cypress-io/cypress/issues/112 const isIP = net.isIP(host) if (isIP) { // isIP conveniently returns the family of the address return cb(isIP) } if (process.env.HTTP_PROXY) { // can't make direct connections through the proxy, this won't work return cb() } if (familyCache[host]) { return cb(familyCache[host]) } return getAddress(port, host) .then((firstWorkingAddress: net.Address) => { familyCache[host] = firstWorkingAddress.family return cb(firstWorkingAddress.family) }) .catch(() => { return cb() }) } export class CombinedAgent { httpAgent: HttpAgent httpsAgent: HttpsAgent familyCache: FamilyCache = {} constructor (httpOpts: http.AgentOptions = {}, httpsOpts: https.AgentOptions = {}) { this.httpAgent = new HttpAgent(httpOpts) this.httpsAgent = new HttpsAgent(httpsOpts) } // called by Node.js whenever a new request is made internally addRequest (req: http.ClientRequest, options: http.RequestOptions, port?: number, localAddress?: string) { // allow requests which contain invalid/malformed headers // https://github.com/cypress-io/cypress/issues/5602 req.insecureHTTPParser = true // Legacy API: addRequest(req, host, port, localAddress) // https://github.com/nodejs/node/blob/cb68c04ce1bc4534b2d92bc7319c6ff6dda0180d/lib/_http_agent.js#L148-L155 if (typeof options === 'string') { // @ts-ignore options = { host: options, port: port!, localAddress, } } const isHttps = isRequestHttps(options) if (!options.href) { // options.path can contain query parameters, which url.format will not-so-kindly urlencode for us... // so just append it to the resultant URL string options.href = url.format({ protocol: isHttps ? 'https:' : 'http:', slashes: true, hostname: options.host, port: options.port, }) + options.path if (!options.uri) { options.uri = url.parse(options.href) } } debug('addRequest called %o', { isHttps, ..._.pick(options, 'href') }) return getFirstWorkingFamily(options, this.familyCache, (family: net.family) => { options.family = family debug('got family %o', _.pick(options, 'family', 'href')) if (isHttps) { return this.httpsAgent.addRequest(req, options) } this.httpAgent.addRequest(req, options) }) } } class HttpAgent extends http.Agent { httpsAgent: https.Agent constructor (opts: http.AgentOptions = {}) { opts.keepAlive = true super(opts) // we will need this if they wish to make http requests over an https proxy this.httpsAgent = new https.Agent({ keepAlive: true }) } addRequest (req: http.ClientRequest, options: http.RequestOptions) { if (process.env.HTTP_PROXY) { const proxy = getProxyForUrl(options.href) if (proxy) { options.proxy = proxy return this._addProxiedRequest(req, <RequestOptionsWithProxy>options) } } super.addRequest(req, options) } _addProxiedRequest (req: http.ClientRequest, options: RequestOptionsWithProxy) { debug(`Creating proxied request for ${options.href} through ${options.proxy}`) const proxy = url.parse(options.proxy) // set req.path to the full path so the proxy can resolve it // @ts-ignore: Cannot assign to 'path' because it is a constant or a read-only property. req.path = options.href delete req._header // so we can set headers again req.setHeader('host', `${options.host}:${options.port}`) if (proxy.auth) { req.setHeader('proxy-authorization', `basic ${Buffer.from(proxy.auth).toString('base64')}`) } // node has queued an HTTP message to be sent already, so we need to regenerate the // queued message with the new path and headers // https://github.com/TooTallNate/node-http-proxy-agent/blob/master/index.js#L93 regenerateRequestHead(req) options.port = Number(proxy.port || 80) options.host = proxy.hostname || 'localhost' delete options.path // so the underlying net.connect doesn't default to IPC if (proxy.protocol === 'https:') { // gonna have to use the https module to reach the proxy, even though this is an http req req.agent = this.httpsAgent return this.httpsAgent.addRequest(req, options) } super.addRequest(req, options) } } class HttpsAgent extends https.Agent { constructor (opts: https.AgentOptions = {}) { opts.keepAlive = true super(opts) } createConnection (options: HttpsRequestOptions, cb: http.SocketCallback) { if (process.env.HTTPS_PROXY) { const proxy = getProxyForUrl(options.href) if (proxy) { options.proxy = <string>proxy return this.createUpstreamProxyConnection(<HttpsRequestOptionsWithProxy>options, cb) } } // @ts-ignore cb(null, super.createConnection(options)) } createUpstreamProxyConnection (options: HttpsRequestOptionsWithProxy, cb: http.SocketCallback) { // heavily inspired by // https://github.com/mknj/node-keepalive-proxy-agent/blob/master/index.js debug(`Creating proxied socket for ${options.href} through ${options.proxy}`) const proxy = url.parse(options.proxy) const port = options.uri.port || '443' const hostname = options.uri.hostname || 'localhost' createProxySock({ proxy, shouldRetry: options.shouldRetry }, (originalErr?, proxySocket?, triggerRetry?) => { if (originalErr) { const err: any = new Error(`A connection to the upstream proxy could not be established: ${originalErr.message}`) err.originalErr = originalErr err.upstreamProxyConnect = true return cb(err, undefined) } const onClose = () => { triggerRetry(new Error('ERR_EMPTY_RESPONSE: The upstream proxy closed the socket after connecting but before sending a response.')) } const onError = (err: Error) => { triggerRetry(err) proxySocket.destroy() } let buffer = '' const onData = (data: Buffer) => { debug(`Proxy socket for ${options.href} established`) buffer += data.toString() if (!_.includes(buffer, _.repeat(CRLF, 2))) { // haven't received end of headers yet, keep buffering proxySocket.once('data', onData) return } // we've now gotten enough of a response not to retry // connecting to the proxy proxySocket.removeListener('error', onError) proxySocket.removeListener('close', onClose) if (!isResponseStatusCode200(buffer)) { return cb(new Error(`Error establishing proxy connection. Response from server was: ${buffer}`), undefined) } if (options._agentKey) { // https.Agent will upgrade and reuse this socket now options.socket = proxySocket // as of Node 12, a ServerName cannot be an IP address // https://github.com/cypress-io/cypress/issues/5729 if (!net.isIP(hostname)) { options.servername = hostname } return cb(undefined, super.createConnection(options, undefined)) } cb(undefined, proxySocket) } proxySocket.once('close', onClose) proxySocket.once('error', onError) proxySocket.once('data', onData) const connectReq = buildConnectReqHead(hostname, port, proxy) proxySocket.setNoDelay(true) proxySocket.write(connectReq) }) } } const agent = new CombinedAgent() export default agent