forward-proxy-tunnel
Version:
Route ClientRequests through a http[s]OverHttp proxy tunnel in seconds.
322 lines (280 loc) • 9.08 kB
JavaScript
;
const { Agent, request: request_http } = require("http");
const { request: request_https, Agent: AgentHTTPS } = require("https");
const { connect: tlsConnect } = require("tls");
const { Readable, pipeline } = require("stream");
const http_connect = request_http;
const debug = {
enable: process.env.NODE_DEBUG && /\bproxy\b/i.test(process.env.NODE_DEBUG),
id: {
"http": {
req: 0,
tcp: 0
},
"https": {
req: 0,
tcp: 0
}
},
socketsMap: new WeakMap()
};
const colorEnabled = checkIsColorEnabled(process.stdout);
let stripAnsiRegEx;
class ProxyTunnel {
constructor(proxy, {
proxyHeaders = {},
defaultHeaders = {},
agentOptions
} = {}) {
this.proxy = proxy instanceof URL ? proxy : new URL(proxy);
this.proxyHeaders = proxyHeaders;
this.defaultHeaders = {
"User-Agent": `node ${process.version}`,
"Accept": "*/*",
...defaultHeaders
};
this.httpAgent = new Agent(agentOptions);
this.httpsAgent = new AgentHTTPS(agentOptions);
this.httpsAgent.createConnection = this.createSecureConnection.bind(this);
}
createSecureConnection(options, cb) {
const { host: hostname, port } = options;
return this.createConnection(
options,
(err, socket) => {
if(err)
return cb(err);
return cb(null, tlsConnect({
host: hostname,
servername: hostname,
port: port,
socket: socket
}));
}
);
}
createConnection({ host: hostname, port }, cb) {
const host = constructHost({ hostname, port });
const onerror = err => cb(connectErrored(err));
const req = http_connect(
this.proxy,
{
method: "CONNECT",
agent: this.httpAgent,
path: host,
setHost: false,
headers: {
"Host": host,
...this.proxyHeaders
}
}
)
.once("connect", (response, socket) => {
if (response.statusCode === 200) {
req.removeListener("error", onerror);
return cb(null, socket);
} else {
socket.destroy();
return cb(connectErrored(`${response.statusCode} ${response.statusMessage}`));
}
})
.once("error", onerror)
.end();
}
destroy() {
this.httpsAgent.destroy();
this.httpAgent.destroy();
}
parseRequestParams (input, options, cb) {
let uriObject;
if(!input)
throw new TypeError(`Forward-proxy-tunnel: Unexpected falsy input ${input}`);
if (typeof input === 'string') {
uriObject = new URL(input);
options = Object.assign({}, options);
} else if (input instanceof URL) {
uriObject = input;
options = Object.assign({}, options);
} else if(typeof input === "object") {
cb = options;
options = Object.assign({}, input);
const protocol = options.protocol || "http:";
const host = options.hostname || options.host;
const port = options.port || options.defaultPort || protocol === "https:" ? "443" : "80";
const path = options.path || "/";
uriObject = new URL(`${protocol}//${host}:${port}${path}`);
delete options.protocol;
delete options.hostname;
delete options.port;
delete options.defaultPort;
delete options.path;
} else {
throw new TypeError(`Forward-proxy-tunnel: Unexpected input ${input}`);
}
return { uriObject, options, cb }
}
request(_input, _options, _cb) {
const { uriObject, options, cb } = this.parseRequestParams(_input, _options, _cb);
// for http.request.options.path
if(options.path) {
uriObject.pathname = options.path;
delete options.path;
}
const headers = options.headers || this.defaultHeaders;
delete options.headers;
const request = (
uriObject.protocol === "https:"
? request_https(
uriObject,
{
agent: this.httpsAgent,
...options,
headers: {
// "Persist": uriObject.hostname,
// "Connection": "keep-alive, persist",
...headers
}
}
)
: request_http(
this.proxy,
{
path: uriObject.toString(),
agent: this.httpAgent,
setHost: false,
...options,
headers: {
Host: constructHost(uriObject),
...this.proxyHeaders,
...headers
}
}
)
);
if(cb) {
if(typeof cb === "function")
request.once("response", cb)
else
throw new TypeError(`Forward-proxy-tunnel: Expected function, but ${typeof cb} is passed as the callback.`);
}
/**
* DEBUG
*/
if (debug.enable) {
const protocol = request.protocol.replace(/(?<=https?):/, "");
const id = debug.id[protocol];
const socketName = ["socket", "tlsSocket"][Number(protocol === "https")];
const id_req = ++id.req;
request
.once("socket", socket => {
if (debug.socketsMap.has(socket)) {
info(
"\x1b[36m%s\x1b[0m", // cyan
`✓ ${protocol} request ${id_req} reusing ${socketName} ${debug.socketsMap.get(socket)}`
);
} else {
const id_tcp = ++id.tcp;
debug.socketsMap.set(socket, id_tcp);
info(`- ${protocol} request ${id_req} using new ${socketName} ${id_tcp}`);
socket.once("close", errored => {
const log = [];
if(request.reusedSocket) {
log.push("\x1b[33m%s\x1b[0m"); // yellow
log.push("Reused");
} else {
log.push("✕ ");
}
log.push(`${socketName} ${id_tcp} for ${protocol} request ${id_req} closed`);
if(errored) {
log.push("\x1b[31mWITH ERROR\x1b[0m"); // red
}
info.apply(void 0, log);
});
}
})
.once("close", () => info(`☓ ${protocol} request ${id_req} closed connection`));
}
/**
* DEBUG END
*/
return request;
}
async fetch (_input, _options) {
const { uriObject, options } = this.parseRequestParams(_input, _options);
const body = options.body;
delete options.body;
return (
new Promise((resolve, reject) => {
const req = (
this.request(uriObject, options)
.once("response", resolve)
.once("error", err => {
if (req.reusedSocket && err.code === 'ECONNRESET') {
req.removeListener("response", resolve);
this.fetch.apply(this, arguments).then(resolve, reject);
} else {
return reject(err);
}
})
);
if(body && req.method === "GET") {
if(!req.getHeader("Content-Length") && !req.getHeader("Transfer-Encoding")) {
// a malformed request, but let's help with some dirty work
info(`\x1b[1m\x1b[30mForward-proxy-tunnel: Found a GET request with non-empty body.\x1b[0m`);
if(body.length) {
req.setHeader("Content-Length", body.length);
} else {
req.setHeader("Transfer-Encoding", "chunked");
}
}
}
if (body instanceof Readable) {
pipeline(
body,
req,
err => err && reject(err)
);
} else {
req.end(body);
}
})
);
}
}
module.exports = ProxyTunnel;
function connectErrored(err) {
return new Error(`connecting to proxy failed with ${err.stack || err}`);
}
function constructHost(uriObject) {
let port = uriObject.port;
if (!port) {
if (uriObject.protocol === "https:") {
port = 443
} else {
port = 80
}
}
return uriObject.hostname.includes(":")
? `[${uriObject.hostname}]:${port}`
: `${uriObject.hostname}:${port}`
;
}
function info (...messages) {
if(!colorEnabled) {
if(!stripAnsiRegEx) {
stripAnsiRegEx = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
}
messages = messages.map(
m => m.replace(stripAnsiRegEx, "")
);
}
return console.info.apply(void 0, messages);
}
function checkIsColorEnabled(tty) {
return "FORCE_COLOR" in process.env
? [1, 2, 3, "", true, "1", "2", "3", "true"].includes(process.env.FORCE_COLOR)
: !(
"NO_COLOR" in process.env ||
process.env.NODE_DISABLE_COLORS == 1 // using == by design
) && tty.isTTY;
}