UNPKG

@httptoolkit/httpolyglot

Version:

Serve http and https connections over the same port with node.js

287 lines (247 loc) 11.5 kB
import * as net from 'net'; import * as tls from 'tls'; import * as http from 'http'; import * as https from 'https'; import * as http2 from 'http2'; import { EventEmitter } from 'events'; // 0x16 first byte is very unlikely to be a false positive as TLS is so widely used & intermediated const isTls = (data: Buffer) => data[0] === 0x16; // SSLv3+ or TLS handshake // H2 hello is effectively guaranteed not to be a false positive, as it's very so specific, but we // need to wait for the full message to confirm this. const HTTP2_PREFACE = Buffer.from('PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n'); const couldBeHttp2 = (data: Buffer) => data[0] === HTTP2_PREFACE[0]; const isHttp2 = (data: Buffer) => data.subarray(0, HTTP2_PREFACE.length).equals(HTTP2_PREFACE); const H1_METHOD_PREFIXES = http.METHODS.map(m => m + ' '); const H1_LONGEST_PREFIX = Math.max(...H1_METHOD_PREFIXES.map(m => m.length)); const couldBeHttp1 = (initialData: Buffer) => { const initialString = initialData.subarray(0, H1_LONGEST_PREFIX).toString('utf8'); for (let method of H1_METHOD_PREFIXES) { const comparisonLength = Math.min(method.length, initialString.length); if (initialString.slice(0, comparisonLength) === method.slice(0, comparisonLength)) { return true; } } return false; }; // [0x4, 0x1|0x2] for SOCKS v4 seems fairly reliable. Some other protocols (Cassandra's CQL) use // the first byte for versions similarly, but shouldn't conflict in practice AFAICT, and no // other popular protocols actively match 0x4 that I can see. const isSocksV4 = (data: Buffer) => { return data[0] === 0x04 && // Start of SOCKS v4 client hello (data.byteLength > 1 && (data[1] === 0x1 || data[1] === 0x2)); // Any command sent is valid } // [0x5, len, len-bytes-of-auth-methods]. 0x5 doesn't have any visible conflicts either, and // using len as a max-expected-length check should keep that fairly tight too. const isSocksV5 = (data: Buffer) => { return data[0] === 0x05 && // Start of SOCKS v5 client hello (data.byteLength > 1 && // If auth method length is present, it's non-zero and could match data[1] > 0 && // (i.e. message isn't longer than it should be) data.byteLength <= 2 + data[1] ); } const isSocks = (data: Buffer) => isSocksV4(data) || isSocksV5(data); function onError(err: any) {} type Http2Listener = (request: http2.Http2ServerRequest, response: http2.Http2ServerResponse) => void; export interface HttpolyglotOptions { /** * Enable support for incoming TLS connections. If a TLS configuration is provided, this will * be used to instantiate a TLS server to handle the connections. If a TLS server is provided, * then all incoming TLS connections will be emitted as 'connection' events on the given * TLS server, and all 'secureConnection' events coming from the TLS server will be handled * according to the connection type detected on that socket. */ tls?: https.ServerOptions | tls.Server; /** * A SOCKS server instance. This will allow incoming SOCKS connections, which will * be emitted as 'connection' events on this server. */ socks?: net.Server; /** * A custom handler for unknown protocols. If provided, any unrecognized protocol sockets will * be emitted on this server as 'connection' events. If not provided, the default behavior is to * pass the socket to the HTTP server, which will typically reject the connection as * unparseable, with a 400 response. */ unknownProtocol?: net.Server; } class Server extends net.Server { private _httpServer: http.Server; private _http2Server: http2.Http2Server; private _tlsServer: EventEmitter; private _socksHandler: EventEmitter | undefined; private _unknownProtocolHandler: EventEmitter | undefined; constructor(config: HttpolyglotOptions, requestListener: http.RequestListener); constructor( configOrListener: | HttpolyglotOptions | http.RequestListener, listener?: http.RequestListener ) { // We just act as a plain TCP server, accepting and examing // each connection, then passing it to the right subserver. super((socket) => this.connectionListener(socket)); let config: HttpolyglotOptions = {}; let requestListener: http.RequestListener; if (typeof configOrListener === 'function') { requestListener = configOrListener; } else { config = configOrListener; requestListener = listener!; } // We bind the request listener, so 'this' always refers to us, not each subserver. // This means 'this' is consistent (and this.close() works). // Use `Function.prototype.bind` directly as frameworks like Express generate // methods from `http.METHODS`, and `BIND` is an included HTTP method. const boundListener = Function.prototype.bind.call(requestListener, this); // Create subservers for each supported protocol: this._httpServer = new http.Server(boundListener); this._http2Server = http2.createServer({}, boundListener as any as Http2Listener); if (config.tls) { if (config.tls instanceof tls.Server) { // If we've been given a preconfigured TLS server, we use that directly, and // subscribe to connections there this._tlsServer = config.tls; this._tlsServer.on('secureConnection', this.tlsListener.bind(this)); } else { // If we have TLS config, create a TLS server, which will pass sockets to // the relevant subserver once the TLS connection is set up. this._tlsServer = new tls.Server(config.tls, this.tlsListener.bind(this)); } } else { // Fake TLS server that rejects all connections: this._tlsServer = new EventEmitter(); this._tlsServer.on('connection', (socket) => socket.destroy()); } this._socksHandler = config.socks; this._unknownProtocolHandler = config.unknownProtocol; const subServers = [this._httpServer, this._http2Server, this._tlsServer]; if (this._socksHandler) subServers.push(this._socksHandler); if (this._unknownProtocolHandler) subServers.push(this._unknownProtocolHandler); // Proxy all event listeners setup onto the subservers, so any // subscriptions on this server are fed from all the subservers this.on('newListener', function (eventName, listener) { subServers.forEach(function (subServer) { subServer.addListener(eventName, listener); }) }); this.on('removeListener', function (eventName, listener) { subServers.forEach(function (subServer) { subServer.removeListener(eventName, listener); }) }); } private connectionListener(socket: net.Socket) { const data = socket.read(); if (data === null) { socket.removeListener('error', onError); socket.on('error', onError); socket.once('readable', () => { this.connectionListener(socket); }); } else { socket.removeListener('error', onError); // Put the peeked data back into the socket socket.unshift(data); // Pass the socket to the correct subserver: if (isTls(data)) { // TLS sockets don't allow half open socket.allowHalfOpen = false; this._tlsServer.emit('connection', socket); } else if (this._socksHandler && isSocks(data)) { this._socksHandler.emit('connection', socket); } else { if (couldBeHttp2(data)) { // The connection _might_ be HTTP/2. To confirm, we need to keep // reading until we get the whole stream: this.http2Listener(socket); } else if (this._unknownProtocolHandler && !couldBeHttp1(data)) { this._unknownProtocolHandler.emit('connection', socket); } else { // We pass everything else to the HTTP server, which can handle requests // and/or reject unparseable client-error connections as it sees fit. this._httpServer.emit('connection', socket); } } } } private tlsListener(tlsSocket: tls.TLSSocket) { if ( tlsSocket.alpnProtocol === false || // Old non-ALPN client tlsSocket.alpnProtocol === 'http/1.1' || // Modern HTTP/1.1 ALPN client tlsSocket.alpnProtocol === 'http 1.1' // Broken ALPN client (e.g. https-proxy-agent) ) { this._httpServer.emit('connection', tlsSocket); } else { this._http2Server.emit('connection', tlsSocket); } } private http2Listener(socket: net.Socket, pastData?: Buffer) { const h1Server = this._httpServer; const h2Server = this._http2Server; const newData: Buffer = socket.read() || Buffer.from([]); const data = pastData ? Buffer.concat([pastData, newData]) : newData; if (data.length >= HTTP2_PREFACE.length) { socket.unshift(data); if (isHttp2(data)) { // We have a full match for the preface - it's definitely HTTP/2. // For HTTP/2 we hit issues when passing non-socket streams (like H2 streams for proxying H2-over-H2). // Setting isStreamBase to false is an effective workaround for this in Node 14+ const socketWithInternals = socket as { _handle?: { isStreamBase?: boolean } }; if (socketWithInternals._handle) { socketWithInternals._handle.isStreamBase = false; } h2Server.emit('connection', socket); return; } else { h1Server.emit('connection', socket); return; } } else if (!data.equals(HTTP2_PREFACE.slice(0, data.length))) { socket.unshift(data); // Haven't finished the preface length, but something doesn't match already h1Server.emit('connection', socket); return; } // Not enough data to know either way - try again, waiting for more: socket.removeListener('error', onError); socket.on('error', onError); socket.once('readable', () => { this.http2Listener.call(this, socket, data); }); } } export type { Server }; /** * Create an Httpolyglot instance with just a request listener to support plain-text * HTTP & HTTP/2 on the same port, with incoming TLS connections being closed immediately. */ export function createServer(requestListener: http.RequestListener): Server; /** * Create an Httpolyglot server with advanced configuration, to enable TLS and/or SOCKS * connections containing HTTP, accepting all protocols on the same port. * * The options can contain: * - `tls`: A TLS configuration object, or an existing TLS server. If a TLS server is provided, * all incoming TLS connections will be emitted as 'connection' events on the given TLS server, * and all 'secureConnection' events coming from the TLS server will be handled according to * the connection type (HTTP/1 or HTTP/2) detected on that socket. * - `socks`: A SOCKS server instance. This will allow incoming SOCKS connections, which will * be emitted as 'connection' events on this server. */ export function createServer(config: HttpolyglotOptions, requestListener: http.RequestListener): Server; export function createServer(configOrListener: | HttpolyglotOptions | http.RequestListener, listener?: http.RequestListener ) { let config: HttpolyglotOptions; let requestListener: http.RequestListener; if (typeof configOrListener === 'function') { config = {} requestListener = configOrListener; } else { config = configOrListener; requestListener = listener!; } return new Server(config, requestListener); };