@vladmandic/piproxy
Version:
Reverse HTTP/HTTPS/HTTP2 Proxy in NodeJS with SSL Certificate Management, Compression, Security Enforcement and Rate Limiting
413 lines (378 loc) • 13.3 kB
text/typescript
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-use-before-define */
/* based on https://github.com/nxtedition/node-http2-proxy by https://github.com/ronag */
import * as net from 'net';
import * as http from 'http';
import * as https from 'https';
import type { TlsOptions, TLSSocket } from 'tls';
import type { Req, Res, Headers } from './server';
const tlsOptions = [
'ca',
'cert',
'ciphers',
'clientCertEngine',
'crl',
'dhparam',
'ecdhCurve',
'honorCipherOrder',
'key',
'passphrase',
'pfx',
'rejectUnauthorized',
'secureOptions',
'secureProtocol',
'servername',
'sessionIdContext',
'highWaterMark',
'checkServerIdentity',
];
const kReq = Symbol('req');
const kRes = Symbol('res');
const kProxyCallback = Symbol('callback');
const kProxyReq = Symbol('proxyReq');
const kProxyRes = Symbol('proxyRes');
const kProxySocket = Symbol('proxySocket');
const kConnected = Symbol('connected');
const kOnRes = Symbol('onRes');
async function request({ req, socket, res = socket, head, proxyName }, onReq, onRes) {
if (req.aborted) return null;
const headers: Headers = getRequestHeaders(req, proxyName);
if (head) {
if (req.method !== 'GET') throw new HttpError('only GET request allowed', undefined, 405);
if (req.headers['upgrade'] !== 'websocket') throw new HttpError('missing upgrade header', undefined, 400);
if (head && head.length) res.unshift(head);
setupSocket(res);
headers['connection'] = 'upgrade';
headers['upgrade'] = 'websocket';
}
const proxyReq = await onReq({ method: req.method, path: req.originalUrl || req.url, headers });
if (req.aborted) {
if (proxyReq?.abort) proxyReq.abort();
else if (proxyReq?.destroy) proxyReq.destroy();
return null;
}
if (req.headers['unknown-protocol']) {
res.statusCode = 421;
res.end('uknown target');
return null;
}
let callback;
const promise = new Promise((resolve, reject) => {
callback = (err) => (err ? reject(err) : resolve(true));
});
req[kRes] = res;
req[kProxyReq] = proxyReq;
res[kReq] = req;
res[kRes] = res;
res[kProxySocket] = null;
res[kProxyRes] = null;
// @ts-ignore callback is assigned in promise
res[kProxyCallback] = callback;
proxyReq[kReq] = req;
proxyReq[kRes] = res;
proxyReq[kConnected] = false;
proxyReq[kOnRes] = onRes;
res
.on('close', onComplete)
.on('finish', onComplete)
.on('error', onComplete);
req
.on('close', onComplete)
.on('aborted', onComplete)
.on('error', onComplete);
proxyReq
.on('error', onProxyReqError)
.on('timeout', onProxyReqTimeout)
.on('response', onProxyReqResponse)
.on('upgrade', onProxyReqUpgrade);
deferToConnect.call(proxyReq);
return promise;
}
function onSocket(this: any, socket: TLSSocket) {
if (!socket.connecting) onProxyConnect.call(this);
else socket.once('connect', onProxyConnect.bind(this));
}
function deferToConnect(this: any) { // eslint-disable-line no-unused-vars
if (this.socket) onSocket.call(this, this.socket);
else this.once('socket', onSocket);
}
function onComplete(this: any, err) {
const res = this[kRes];
const req = res[kReq];
if (!res[kProxyCallback]) return;
const proxyReq = req[kProxyReq];
const proxySocket = res[kProxySocket];
const proxyRes = res[kProxyRes];
const callback = res[kProxyCallback];
req[kProxyReq] = null;
res[kProxySocket] = null;
res[kProxyRes] = null;
res[kProxyCallback] = null;
res
.off('close', onComplete)
.off('finish', onComplete)
.off('error', onComplete);
req
.off('close', onComplete)
.off('aborted', onComplete)
.off('error', onComplete)
.off('data', onReqData)
.off('end', onReqEnd);
if (err) {
err.connectedSocket = Boolean(proxyReq && proxyReq[kConnected]);
err.reusedSocket = Boolean(proxyReq && proxyReq.reusedSocket);
}
if (proxyReq) {
proxyReq.off('drain', onProxyReqDrain);
if (proxyReq.abort) proxyReq.abort();
else if (proxyReq.destroy) proxyReq.destroy();
}
if (proxySocket) proxySocket.destroy();
if (proxyRes) proxyRes.destroy();
callback(err);
}
function onProxyConnect(this: any) { // eslint-disable-line no-unused-vars
this[kConnected] = true;
if (this['method'] === 'GET' || this['method'] === 'HEAD' || this['method'] === 'OPTIONS') {
this[kReq].resume();
this.end();
} else {
this[kReq]
.on('data', onReqData)
.on('end', onReqEnd);
this
.on('drain', onProxyReqDrain);
}
}
function onReqEnd(this: any) { // eslint-disable-line no-unused-vars
this[kProxyReq].end();
}
function onReqData(this: any, buf) {
if (!this[kProxyReq].write(buf)) this.pause();
}
function onProxyReqDrain(this: any) { // eslint-disable-line no-unused-vars
this[kReq].resume();
}
function onProxyReqError(this: any, err) {
err.statusCode = this[kConnected] ? 502 : 503;
onComplete.call(this, err);
}
function onProxyReqTimeout(this: any) { // eslint-disable-line no-unused-vars
onComplete.call(this, new HttpError('proxy timeout', 'ETIMEDOUT', 504));
}
async function onProxyReqResponse(this: any, proxyRes) {
const res = this[kRes];
res[kProxyRes] = proxyRes;
proxyRes[kRes] = res;
const headers: Headers = setupHeaders(proxyRes.headers);
proxyRes.on('aborted', onProxyResAborted).on('error', onProxyResError);
if (this[kOnRes]) {
try {
await this[kOnRes](proxyRes, headers);
} catch (err) {
onComplete.call(this, err);
}
} else if (!res.writeHead) {
if (!proxyRes.upgrade) {
res.write(createHttpHeader(`HTTP/${proxyRes.httpVersion} ${proxyRes.statusCode} ${proxyRes.statusMessage}`, proxyRes.headers));
proxyRes.pipe(res);
}
} else {
res.statusCode = proxyRes.statusCode;
for (const [key, value] of Object.entries(headers)) res.setHeader(key, value);
proxyRes.on('end', onProxyResEnd).pipe(res);
}
}
function onProxyReqUpgrade(this: any, proxyRes, proxySocket, proxyHead) {
const res = this[kRes];
res[kProxySocket] = proxySocket;
proxySocket[kRes] = res;
setupSocket(proxySocket);
if (proxyHead && proxyHead.length) proxySocket.unshift(proxyHead);
res.write(createHttpHeader('HTTP/1.1 101 Switching Protocols', proxyRes.headers));
proxySocket
.on('error', onProxyResError)
.on('close', onProxyResAborted)
.pipe(res)
.pipe(proxySocket);
}
function onProxyResError(this: any, err) {
err.statusCode = 502;
onComplete.call(this, err);
}
function onProxyResAborted(this: any) { // eslint-disable-line no-unused-vars
onComplete.call(this, new HttpError('proxy aborted', 'ECONNRESET', 502));
}
function onProxyResEnd(this: any) { // eslint-disable-line no-unused-vars
if (this.trailers) this[kRes].addTrailers(this.trailers);
}
function createHttpHeader(line: string, headers: Headers) {
let head = line;
for (const [key, value] of Object.entries(headers)) {
if (!Array.isArray(value)) {
head += `\r\n${key}: ${value}`;
} else {
for (let i = 0; i < value.length; i++) head += `\r\n${key}: ${value[i]}`;
}
}
head += '\r\n\r\n';
return Buffer.from(head, 'ascii');
}
function getRequestHeaders(req: { headers: Headers; httpVersion: any; socket: { localAddress: any; localPort: any; remoteAddress: any; remotePort: any; encrypted: any; }; }, proxyName: any) {
const headers = {};
for (const [key, value] of Object.entries(req.headers)) {
if (key.charAt(0) !== ':' && key !== 'host') headers[key] = value;
}
if (proxyName) {
if (headers['via']) {
for (const name of headers['via'].split(',')) {
if (name.endsWith(proxyName)) throw new HttpError('loop detected', undefined, 508);
}
headers['via'] += ',';
} else {
headers['via'] = '';
}
headers['via'] += `${req.httpVersion} ${proxyName}`;
}
function printIp(address: string, port?: number) {
const isIPv6 = net.isIPv6(address);
let str = `${address}`;
if (isIPv6) str = `[${str}]`;
if (port) str = `${str}:${port}`;
if (isIPv6 || port) str = `"${str}"`;
return str;
}
const forwarded = [
`by=${printIp(req.socket.localAddress, req.socket.localPort)}`,
`for=${printIp(req.socket.remoteAddress, req.socket.remotePort)}`,
`proto=${req.socket.encrypted ? 'https' : 'http'}`,
`host=${printIp(req.headers[':authority'] as string || req.headers['host'] as string || '')}`,
].join(';');
if (headers['forwarded']) headers['forwarded'] += `, ${forwarded}`;
else headers['forwarded'] = `${forwarded}`;
return setupHeaders(headers);
}
function setupSocket(socket: TLSSocket) {
socket.setTimeout(0);
socket.setNoDelay(true);
socket.setKeepAlive(true, 0);
}
function setupHeaders(headers: Headers) {
if (headers['connection'] && headers['connection'] !== 'connection' && headers['connection'] !== 'keep-alive') {
for (const name of headers['connection'].toLowerCase().split(',')) delete headers[name.trim()];
}
delete headers['connection'];
delete headers['proxy-connection'];
delete headers['keep-alive'];
delete headers['proxy-authenticate'];
delete headers['proxy-authorization'];
delete headers['te'];
delete headers['trailer'];
delete headers['transfer-encoding'];
delete headers['upgrade'];
delete headers['http2-settings'];
return headers;
}
class HttpError extends Error {
code: string | undefined;
statusCode: number;
constructor(msg: string | undefined, code: string | undefined, statusCode: number) {
super(msg);
this.code = code;
this.statusCode = statusCode || 500;
}
}
type Proxy = {
({ req, socket, res, head, proxyName }: { req: Req; socket: TlsOptions; res?: Res; head: Headers; proxyName: string; }, onReq, onRes): Promise<unknown>; // eslint-disable-line no-unused-vars
(arg0, arg1, arg2); // eslint-disable-line no-unused-vars
(arg0, arg1, arg2); // eslint-disable-line no-unused-vars
ws?;
web?;
}
export function process(proxy: Proxy) {
proxy.ws = function ws(req: Req, socket: TLSSocket, head: Headers, options: any, callback) {
const promise = compat({ req, socket, head }, options);
if (!callback) return promise;
return promise
.then(() => callback(null, req, socket, head))
.catch((err) => callback(err, req, socket, head));
};
proxy.web = function web(req: Req, res: Res, options: any, callback) {
const promise = compat({ req, res }, options);
if (!callback) return promise;
return promise
.then(() => callback(null, req, res))
.catch((err) => callback(err, req, res));
};
type Options = {
hostname: string,
port: number,
path: string,
socketPath: string,
protocol: string,
timeout: number,
proxyTimeout: number,
proxyName: string,
onReq,
onRes,
}
async function compat(ctx: { req: Req; socket?: TLSSocket; head?: Headers; res?: Res; }, options: Options) {
const { req, res } = ctx;
const { hostname, port, path, socketPath, protocol, timeout, proxyTimeout, proxyName, onReq, onRes } = options;
if (timeout) req.setTimeout(timeout);
await proxy(
{ ...ctx, proxyName },
async (ureq: https.RequestOptions) => {
for (const key of tlsOptions) {
if (Reflect.has(options, key)) {
const value = Reflect.get(options, key);
Reflect.set(ureq, key, value);
}
}
if (hostname) ureq.hostname = hostname;
if (port) ureq.port = port;
if (path) ureq.path = path;
if (proxyTimeout) ureq.timeout = proxyTimeout;
if (socketPath) ureq.socketPath = socketPath;
let ret: http.ClientRequest | undefined;
if (onReq) {
if (onReq.length <= 2) {
ret = await onReq(req, ureq);
} else {
ret = await new Promise((resolve, reject) => {
const promiseOrReq = onReq(req, ureq, (err, val: http.ClientRequest | Promise<http.ClientRequest>) => (err ? reject(err) : resolve(val)));
if (promiseOrReq) {
if (promiseOrReq.then) promiseOrReq.then(resolve).catch(reject);
else if (promiseOrReq.abort) resolve(promiseOrReq);
else throw new Error('onReq must return a promise or a request object');
} else {
reject();
}
});
}
}
if (!ret) {
let agent;
if (protocol == null || /^(http|ws):?$/.test(protocol)) agent = http;
else if (/^(http|ws)s:?$/.test(protocol)) agent = https;
else throw new Error('invalid protocol');
if (ureq.hostname && ureq.port) ret = agent.request(ureq);
else req.headers['unknown-protocol'] = 'true';
}
return ret;
},
onRes ? async (proxyRes: { headers: Headers }, headers: Headers) => {
proxyRes.headers = headers;
if (onRes.length <= 3) return onRes(req, res, proxyRes);
return new Promise((resolve, reject) => {
const promise = onRes(req, res, proxyRes, (err, val) => (err ? reject(err) : resolve(val)));
if (promise && promise.then) {
promise.then(resolve).catch(reject);
}
});
} : null,
);
}
return proxy;
}
export default process(request);