Files
cypress/packages/https-proxy/lib/server.js

361 lines
9.8 KiB
JavaScript

/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const _ = require("lodash");
const { agent, allowDestroy, connect } = require("@packages/network");
const debug = require("debug")("cypress:https-proxy");
let fs = require("fs-extra");
const {
getProxyForUrl
} = require("proxy-from-env");
const https = require("https");
const net = require("net");
const parse = require("./util/parse");
const Promise = require("bluebird");
const semaphore = require("semaphore");
const url = require("url");
fs = Promise.promisifyAll(fs);
let sslServers = {};
let sslIpServers = {};
const sslSemaphores = {};
//# https://en.wikipedia.org/wiki/Transport_Layer_Security#TLS_record
const SSL_RECORD_TYPES = [
22, //# Handshake
128, 0 //# TODO: what do these unknown types mean?
];
let onError = err => //# these need to be caught to avoid crashing but do not affect anything
debug('server error %o', { err });
class Server {
constructor(_ca, _port, _options) {
this._getServerPortForIp = this._getServerPortForIp.bind(this);
this._ca = _ca;
this._port = _port;
this._options = _options;
this._onError = null;
this._ipServers = sslIpServers;
}
connect(req, browserSocket, head, options = {}) {
//# don't buffer writes - thanks a lot, Nagle
//# https://github.com/cypress-io/cypress/issues/3192
browserSocket.setNoDelay(true);
debug("Writing browserSocket connection headers %o", { url: req.url, headLength: _.get(head, 'length'), headers: req.headers });
browserSocket.on("error", err => {
//# TODO: shouldn't we destroy the upstream socket here?
//# and also vise versa if the upstream socket throws?
//# we may get this "for free" though because piping will
//# automatically forward the TCP errors...?
//# nothing to do except catch here, the browser has d/c'd
return debug("received error on client browserSocket %o", {
err, url: req.url
});
});
browserSocket.write("HTTP/1.1 200 OK\r\n");
if (req.headers["proxy-connection"] === "keep-alive") {
browserSocket.write("Proxy-Connection: keep-alive\r\n");
browserSocket.write("Connection: keep-alive\r\n");
}
browserSocket.write("\r\n");
//# if we somehow already have the head here
if (_.get(head, "length")) {
//# then immediately make up the connection
return this._onFirstHeadBytes(req, browserSocket, head, options);
}
//# else once we get it make the connection later
return browserSocket.once("data", data => {
return this._onFirstHeadBytes(req, browserSocket, data, options);
});
}
_onFirstHeadBytes(req, browserSocket, head, options) {
let odc;
debug("Got first head bytes %o", { url: req.url, head: _.chain(head).invoke('toString').slice(0, 64).join('').value() });
browserSocket.pause();
if (odc = options.onDirectConnection) {
//# if onDirectConnection return true
//# then dont proxy, just pass this through
if (odc.call(this, req, browserSocket, head) === true) {
return this._makeDirectConnection(req, browserSocket, head);
} else {
debug("Not making direct connection %o", { url: req.url });
}
}
return this._onServerConnectData(req, browserSocket, head);
}
_onUpgrade(fn, req, browserSocket, head) {
if (fn) {
return fn.call(this, req, browserSocket, head);
}
}
_onRequest(fn, req, res) {
const hostPort = parse.hostAndPort(req.url, req.headers, 443);
req.url = url.format({
protocol: "https:",
hostname: hostPort.host,
port: hostPort.port
}) + req.url;
if (fn) {
return fn.call(this, req, res);
}
}
_getProxyForUrl(urlStr) {
const port = Number(_.get(url.parse(urlStr), 'port'));
debug('getting proxy URL %o', { port, serverPort: this._port, sniPort: this._sniPort, url: urlStr });
if ([this._sniPort, this._port].includes(port)) {
//# https://github.com/cypress-io/cypress/issues/4257
//# this is a tunnel to the SNI server or to the main server,
//# it should never go through a proxy
return undefined;
}
return getProxyForUrl(urlStr);
}
_makeDirectConnection(req, browserSocket, head) {
const { port, hostname } = url.parse(`https://${req.url}`);
debug(`Making connection to ${hostname}:${port}`);
return this._makeConnection(browserSocket, head, port, hostname);
}
_makeConnection(browserSocket, head, port, hostname) {
let upstreamProxy;
const onSocket = (err, upstreamSocket) => {
debug('received upstreamSocket callback for request %o', { port, hostname, err });
onError = err => {
browserSocket.destroy(err);
if (this._onError) {
return this._onError(err, browserSocket, head, port);
}
};
if (err) {
return onError(err);
}
upstreamSocket.setNoDelay(true);
upstreamSocket.on("error", onError);
browserSocket.emit('upstream-connected', upstreamSocket);
browserSocket.pipe(upstreamSocket);
upstreamSocket.pipe(browserSocket);
upstreamSocket.write(head);
return browserSocket.resume();
};
if (!port) { port = "443"; }
if (upstreamProxy = this._getProxyForUrl(`https://${hostname}:${port}`)) {
// todo: as soon as all requests are intercepted, this can go away since this is just for pass-through
debug("making proxied connection %o", {
host: `${hostname}:${port}`,
proxy: upstreamProxy,
});
return agent.httpsAgent.createUpstreamProxyConnection({
proxy: upstreamProxy,
href: `https://${hostname}:${port}`,
uri: {
port,
hostname
},
shouldRetry: true
}, onSocket);
}
return connect.createRetryingSocket({ port, host: hostname }, onSocket);
}
_onServerConnectData(req, browserSocket, head) {
let sem, sslServer;
const firstBytes = head[0];
const makeConnection = port => {
debug("Making intercepted connection to %s", port);
return this._makeConnection(browserSocket, head, port, "localhost");
};
if (!SSL_RECORD_TYPES.includes(firstBytes)) {
//# if this isn't an SSL request then go
//# ahead and make the connection now
return makeConnection(this._port);
}
//# else spin up the SNI server
const { hostname } = url.parse(`https://${req.url}`);
if (sslServer = sslServers[hostname]) {
return makeConnection(sslServer.port);
}
//# only be creating one SSL server per hostname at once
if (!(sem = sslSemaphores[hostname])) {
sem = (sslSemaphores[hostname] = semaphore(1));
}
return sem.take(() => {
const leave = () => process.nextTick(() => sem.leave());
if (sslServer = sslServers[hostname]) {
leave();
return makeConnection(sslServer.port);
}
return this._getPortFor(hostname)
.then(function(port) {
sslServers[hostname] = { port };
leave();
return makeConnection(port);
});
});
}
_normalizeKeyAndCert(certPem, privateKeyPem) {
return {
key: privateKeyPem,
cert: certPem
};
}
_getCertificatePathsFor(hostname) {
return this._ca.getCertificateKeysForHostname(hostname)
.spread(this._normalizeKeyAndCert);
}
_generateMissingCertificates(hostname) {
return this._ca.generateServerCertificateKeys(hostname)
.spread(this._normalizeKeyAndCert);
}
_getPortFor(hostname) {
return this._getCertificatePathsFor(hostname)
.catch(err => {
return this._generateMissingCertificates(hostname);
}).then((data = {}) => {
if (net.isIP(hostname)) {
return this._getServerPortForIp(hostname, data);
}
this._sniServer.addContext(hostname, data);
return this._sniPort;
});
}
_listenHttpsServer(data) {
return new Promise((resolve, reject) => {
const server = https.createServer(data);
allowDestroy(server);
server.once("error", reject);
server.on("upgrade", this._onUpgrade.bind(this, this._options.onUpgrade));
server.on("request", this._onRequest.bind(this, this._options.onRequest));
return server.listen(0, '127.0.0.1', () => {
const {
port
} = server.address();
server.removeListener("error", reject);
server.on("error", onError);
return resolve({ server, port });
});
});
}
//# browsers will not do SNI for an IP address
//# so we need to serve 1 HTTPS server per IP
//# https://github.com/cypress-io/cypress/issues/771
_getServerPortForIp(ip, data) {
let server;
if (server = sslIpServers[ip]) {
return server.address().port;
}
return this._listenHttpsServer(data)
.then(function({ server, port }) {
sslIpServers[ip] = server;
debug("Created IP HTTPS Proxy Server", { port, ip });
return port;
});
}
listen() {
this._onError = this._options.onError;
return this._listenHttpsServer({})
.tap(({ server, port}) => {
this._sniPort = port;
this._sniServer = server;
return debug("Created SNI HTTPS Proxy Server", { port });
});
}
close() {
const close = () => {
const servers = _.values(sslIpServers).concat(this._sniServer);
return Promise.map(servers, server => {
return Promise.fromCallback(server.destroy)
.catch(onError);
});
};
return close()
.finally(module.exports.reset);
}
}
module.exports = {
reset() {
sslServers = {};
return sslIpServers = {};
},
create(ca, port, options = {}) {
const srv = new Server(ca, port, options);
return srv
.listen()
.return(srv);
}
};