diff --git a/README.md b/README.md index 6d15b697..e5fdad53 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ proxay --mode passthrough --host https://api.website.com You can also run several instances of Proxay simultaneously on different ports (for example to proxy multiple backends). Just pick a different port (e.g. `--port 3001`). +If you want proxay to accept incoming requests on HTTPS in addition to HTTP, you can provide the HTTPS key and certificate files in PEM format on the `--https-key` and `--https-cert` arguments respectively. If the HTTPS certificate is self-signed, you probably also want to provide the CA certificate in PEM format on `--https-ca`. See the `key`, `cert`, and `ca` arguments to [`tls.createSecureContext`](https://nodejs.org/api/tls.html#tlscreatesecurecontextoptions) for what these mean. + ## Specifying a tape If you have several tests, you likely want to save recorded interactions into one tape per test, diff --git a/src/cli.ts b/src/cli.ts index f913c0f3..dcade898 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -28,6 +28,18 @@ async function main(argv: string[]) { "--no-drop-conditional-request-headers", "When running in record mode, by default, `If-*` headers from outgoing requests are dropped in an attempt to prevent the suite of conditional responses being returned (e.g. 304). Supplying this flag disables this default behaviour" ) + .option( + "--https-key ", + "Enable HTTPS server with this key. Also requires --https-cert." + ) + .option( + "--https-cert ", + "Enable HTTPS server with this cert. Also requires --https-key." + ) + .option( + "--https-ca ", + "Enable HTTPS server where the certificate was generated by this CA. Useful if you are using a self-signed certificate. Also requires --https-key and --https-cert." + ) .parse(argv); const initialMode: string = (program.mode || "").toLowerCase(); @@ -37,6 +49,9 @@ async function main(argv: string[]) { const port = parseInt(program.port, 10); const redactHeaders: string[] = program.redactHeaders; const preventConditionalRequests: boolean = !!program.dropConditionalRequestHeaders; + const httpsCA: string = program.httpsCa || ""; + const httpsKey: string = program.httpsKey; + const httpsCert: string = program.httpsCert; switch (initialMode) { case "record": @@ -84,6 +99,9 @@ async function main(argv: string[]) { enableLogging: true, redactHeaders, preventConditionalRequests, + httpsCA, + httpsKey, + httpsCert, }); await server.start(port); console.log(chalk.green(`Proxying in ${initialMode} mode on port ${port}.`)); diff --git a/src/server.ts b/src/server.ts index 1875d905..3d025909 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,9 @@ import assertNever from "assert-never"; import chalk from "chalk"; +import fs from "fs"; import http from "http"; +import https from "https"; +import net from "net"; import { ensureBuffer } from "./buffer"; import { findNextRecordToReplay, findRecordMatches } from "./matcher"; import { Mode } from "./modes"; @@ -12,7 +15,7 @@ import { TapeRecord } from "./tape"; * A server that proxies or replays requests depending on the mode. */ export class RecordReplayServer { - private server: http.Server; + private server: net.Server; private persistence: Persistence; private mode: Mode; @@ -34,6 +37,9 @@ export class RecordReplayServer { enableLogging?: boolean; redactHeaders?: string[]; preventConditionalRequests?: boolean; + httpsCA?: string; + httpsKey?: string; + httpsCert?: string; }) { this.currentTapeRecords = []; this.mode = options.initialMode; @@ -46,7 +52,10 @@ export class RecordReplayServer { this.preventConditionalRequests = options.preventConditionalRequests; this.loadTape(this.defaultTape); - this.server = http.createServer(async (req, res) => { + const handler = async ( + req: http.IncomingMessage, + res: http.ServerResponse + ) => { if (!req.url) { if (this.loggingEnabled) { console.error(chalk.red("Received a request without URL.")); @@ -97,6 +106,57 @@ export class RecordReplayServer { res.statusCode = 500; res.end(); } + }; + + const httpServer = http.createServer(handler); + let httpsServer: https.Server | null = null; + if (options.httpsKey && options.httpsCert) { + const httpsOptions = { + ca: options.httpsCA ? fs.readFileSync(options.httpsCA) : undefined, + key: fs.readFileSync(options.httpsKey), + cert: fs.readFileSync(options.httpsCert), + }; + httpsServer = https.createServer(httpsOptions, handler); + } + + // Copied from https://stackoverflow.com/a/42019773/16286019 + this.server = net.createServer((socket) => { + socket.once("data", (buffer) => { + // Pause the socket + socket.pause(); + + // Determine if this is an HTTP(s) request + const byte = buffer[0]; + + let server; + // First byte of HTTPS is a 22. + if (byte === 22) { + server = httpsServer; + } else if (32 < byte && byte < 127) { + server = httpServer; + } else { + console.error( + chalk.red( + `Unexpected starting byte of incoming request: ${byte}. Dropping request.` + ) + ); + } + + if (server) { + // Push the buffer back onto the front of the data stream + socket.unshift(buffer); + + // Emit the socket to the HTTP(s) server + server.emit("connection", socket); + } + + // As of NodeJS 10.x the socket must be + // resumed asynchronously or the socket + // connection hangs, potentially crashing + // the process. Prior to NodeJS 10.x + // the socket may be resumed synchronously. + process.nextTick(() => socket.resume()); + }); }); } @@ -125,6 +185,7 @@ export class RecordReplayServer { request.path === "/__proxay" ) { res.end("Proxay!"); + return; } // Sending a request to /__proxay/tape will pick a specific tape and/or a new mode. @@ -166,7 +227,12 @@ export class RecordReplayServer { this.unloadTape(); res.end(`Unloaded tape`); } + return; } + + // If we got here, we don't know what to do. Return a 404. + res.statusCode = 404; + res.end(`Unhandled proxay request.\n\n${JSON.stringify(request)}`); } private async fetchResponse(request: Request): Promise {