@vladmandic/piproxy
Version:
Reverse HTTP/HTTPS/HTTP2 Proxy in NodeJS with SSL Certificate Management, Compression, Security Enforcement and Rate Limiting
182 lines (165 loc) • 8.27 kB
text/typescript
import * as fs from 'fs';
import * as os from 'os';
import * as zlib from 'zlib';
import * as path from 'path';
import * as http from 'http';
import * as http2 from 'http2';
import * as process from 'process';
import type { TLSSocket } from 'tls';
import * as log from '@vladmandic/pilogger';
import logger from './logger';
import proxy from './proxy';
import * as middleware from './middleware';
import * as errors from './errors';
import * as answers from './answers';
import * as config from './config';
export type Req = http.IncomingMessage | http2.Http2ServerRequest;
export type Res = http.ServerResponse | http2.Http2ServerResponse;
export type Headers = http.IncomingHttpHeaders | http2.IncomingHttpHeaders;
let app;
let server;
let ssl;
let cfg = config.get();
function errorHandler(err, req: Req, res: Res) {
if (err) {
const client = `${req.headers[':scheme'] || ((req.socket as TLSSocket).encrypted ? 'https' : 'http')}://${req.headers[':authority'] || req.headers.host}${req.url}`;
if (err.statusCode) log.error('Proxy', { client, status: err.statusCode, code: err.code, address: err.address, port: err.port });
else log.error('Proxy', { client, err });
res.setHeader('proxy-error', err);
if (err.statusCode) res.writeHead(err.statusCode, req.headers);
res.end();
}
}
function redirectSecure() {
if (!cfg.redirectHTTP) return;
const redirector = http.createServer((req: Req, res: Res) => {
res.writeHead(301, { Location: `https://${req.headers.host}${req.url}` });
res.end();
logger(req, req);
});
redirector.on('error', (err) => log.error('server', { message: err.message || err }));
redirector.on('close', () => log.state('server', { status: 'closed' }));
redirector.listen(80);
}
function writeHeaders(input: Req, output: Res, compress: boolean) {
// some heads are in input and some are already present in output, but no longer in input
const tempHeaders = [...Object.entries(output.getHeaders())];
for (const key of Object.keys(output.getHeaders())) output.removeHeader(key);
for (const [key, val] of Object.entries(input.headers)) {
if (compress && key.toLowerCase().includes('content-length')) output.setHeader('content-size', val as string);
else output.setHeader(key, val as string);
delete input.headers[key];
}
// rewrite original headers
for (const header of tempHeaders) {
output.setHeader(header[0], header[1] || '');
}
if (!output.getHeader('content-type')?.toString().startsWith('text/html')) output.removeHeader('content-security-policy');
output.setHeader('x-content-type-options', 'nosniff');
if (compress) output.setHeader('content-encoding', 'br'); // brotli compression
}
function writeData(req: Req, input: Req, output: Res) {
const encoding = (input.headers['content-encoding'] || '').length > 0; // is content already compressed?
const accept = req.headers['accept-encoding'] ? req.headers['accept-encoding'].includes('br') : false; // does target accept compressed data? // gzip
const acceptCompress = cfg.compress ? ((cfg.compress > 0) && !encoding && accept) : false; // is compression enabled, data uncompressed and target accepts compression?
writeHeaders(input, output, acceptCompress); // copy headers from original response
// override cors headers
output.setHeader('Cross-Origin-Embedder-Policy', 'unsafe-none');
output.setHeader('Cross-Origin-Opener-Policy', 'unsafe-none');
output.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
const compress = zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: cfg.compress } }); // zlib.createGzip({ level: cfg.compress });
if (!acceptCompress) input.pipe(output); // don't compress data
else input.pipe(compress).pipe(output); // compress data
return output;
}
type Target = { timestamp: bigint; onReq: (input: Req, output: Req) => void; onRes: (req: Req, output: Res, proxyReq: Req) => Res; }; // eslint-disable-line no-unused-vars
const findTarget: Target = {
timestamp: 0n,
onReq: (clientReq: Req, outputReq: http.RequestOptions) => { // add headers to request going to target server
clientReq.headers['timestamp'] = process.hrtime.bigint().toString();
const url = `${clientReq.headers[':scheme']}://${clientReq.headers[':authority']}${clientReq.headers[':path']}`;
const tgt = cfg.redirects.find((a) => url.match(a.url || 'missing-url-property')) || cfg.redirects.find((a) => a.default === true);
outputReq.hostname = tgt?.target;
outputReq.port = tgt?.port;
if (!tgt) {
// @ts-ignore we preset status code on request even if its not ready yet
clientReq.statusCode = 421;
logger(clientReq, clientReq);
}
if (!outputReq.headers) outputReq.headers = {};
outputReq.headers['x-forwarded-for'] = clientReq.socket.remoteAddress;
outputReq.headers['x-forwarded-proto'] = (clientReq.socket as TLSSocket).encrypted ? 'https' : 'http';
outputReq.headers['x-forwarded-host'] = clientReq.headers[':authority'] || clientReq.headers.host;
},
onRes: (clientReq: Req, final: Res, proxyReq: Req) => {
final.statusCode = (proxyReq as http.IncomingMessage).statusCode || 503;
const obj = logger(clientReq, proxyReq);
switch ((proxyReq as http.IncomingMessage).statusCode) {
case 404:
final.setHeader('content-security-policy', "default-src 'self' 'unsafe-inline'");
final.setHeader('content-type', 'text/html');
final.end(errors.get404(obj));
return final;
default:
return writeData(clientReq, proxyReq, final);
}
},
};
function dropPriviledges() {
log.state('server', { user: os.userInfo() });
const uid = parseInt(process.env.SUDO_UID || '');
if (uid) {
if (process.setuid) process.setuid(uid);
log.state('server user override', { uid: process.getuid ? process.getuid() : 'unknown' });
}
}
function startServer() {
// Start Proxy web server
let key;
let cert;
if (!fs.existsSync(path.join(__dirname, ssl.key))) log.warn('ssl key missing:', ssl);
else key = fs.readFileSync(path.join(__dirname, ssl.key));
if (!fs.existsSync(path.join(__dirname, ssl.crt))) log.warn('ssl key missing:', ssl);
else cert = fs.readFileSync(path.join(__dirname, ssl.crt));
if (!key || !cert) log.warn('server', { ssl: 'fail' });
server = http2.createSecureServer({ ...cfg.http2, key, cert });
server.on('listening', () => {
log.state('server', { status: 'listening', ...server.address() });
dropPriviledges();
});
server.on('error', (err) => log.error('server', { message: err.message || err }));
server.on('close', () => log.state('server', { status: 'closed' }));
// server.on('connect', (req, socket, head) => log.state('server connect', { req, socket, head }));
// server.on('socket', (socket) => log.state('server socket', { socket }));
server.on('request', app);
server.listen(cfg.http2.port);
}
export function restartServer() {
if (!server) {
log.warn('server', { status: 'not started' });
return;
}
log.info('server', { status: 'restarting' });
server.close();
setTimeout(() => startServer(), 2000);
}
export function checkServer() {
server.getConnections((error, connections) => {
if (server.listening) log.state('server', { status: 'active', connections, error });
else log.error('server', { status: 'not listening', connections, error });
});
setTimeout(checkServer, 60000); // Monitor server status
}
export async function init(sslOptions) {
ssl = sslOptions;
cfg = config.get(); // Read current config
await redirectSecure(); // Redirect HTTP to HTTPS
app = await middleware.init(); // Initialize server middleware
startServer(); // Start proxy web server
if (!cfg.redirects || (cfg.redirects.length <= 0)) log.warn('Proxy', { rules: 0 });
for (const rule of cfg.redirects) log.info('Proxy', rule); // Log all redirect rules
log.info('Static', { paths: Object.keys(cfg.answers) }); // Log all predefined answers
app.use((req: Req, res: Res, next) => answers.get(req, res, next)); // Enable predefined answers
app.use((req: Req, res: Res) => proxy.web(req, res, findTarget, errorHandler)); // Actual proxy calls
setTimeout(checkServer, 5000); // Check if server started correctly
}