diff --git a/lib/http-proxy/passes/web-incoming.ts b/lib/http-proxy/passes/web-incoming.ts index dd8cc81..fcbedb6 100644 --- a/lib/http-proxy/passes/web-incoming.ts +++ b/lib/http-proxy/passes/web-incoming.ts @@ -9,7 +9,7 @@ The names of passes are exported as WEB_PASSES from this module. import * as http from "node:http"; import * as https from "node:https"; -import { OUTGOING_PASSES } from "./web-outgoing"; +import { OUTGOING_PASSES, EditableResponse } from "./web-outgoing"; import * as common from "../common"; import * as followRedirects from "follow-redirects"; import { @@ -164,7 +164,7 @@ export function stream(req: Request, res: Response, options: NormalizedServerOpt if (!res.headersSent && !options.selfHandleResponse) { for (const pass of web_o) { // note: none of these return anything - pass(req, res, proxyRes, options as NormalizedServerOptions & { target: NormalizeProxyTarget }); + pass(req, res as EditableResponse, proxyRes, options as NormalizedServerOptions & { target: NormalizeProxyTarget }); } } diff --git a/lib/http-proxy/passes/web-outgoing.ts b/lib/http-proxy/passes/web-outgoing.ts index 1c58811..8f81cfd 100644 --- a/lib/http-proxy/passes/web-outgoing.ts +++ b/lib/http-proxy/passes/web-outgoing.ts @@ -11,29 +11,36 @@ to not return anything. import type { NormalizedServerOptions, NormalizeProxyTarget, ProxyTarget } from ".."; import * as common from "../common"; -import type { Request, Response, ProxyResponse } from "./web-incoming"; +import type { Request, ProxyResponse } from "./web-incoming"; const redirectRegex = /^201|30(1|2|7|8)$/; +// interface for subset of Response that's actually used here +// needed for running outgoing passes on MockResponse in ws-incoming +export interface EditableResponse { + statusCode: number; + statusMessage: string; + setHeader(key: string, value: string | string[]): this; +} + // <-- // If is a HTTP 1.0 request, remove chunk headers export function removeChunked( - req: Request, - _res: Response, + _req: Request, + _res: EditableResponse, // Response object from the proxy request proxyRes: ProxyResponse, ) { - if (req.httpVersion === "1.0") { - delete proxyRes.headers["transfer-encoding"]; - } + // transfer-encoding is hop-by-hop, don't preserve it across proxy hops + delete proxyRes.headers["transfer-encoding"]; } // If is a HTTP 1.0 request, set the correct connection header // or if connection header not present, then use `keep-alive` export function setConnection( req: Request, - _res: Response, + _res: EditableResponse, // Response object from the proxy request proxyRes: ProxyResponse, ) { @@ -46,7 +53,7 @@ export function setConnection( export function setRedirectHostRewrite( req: Request, - _res: Response, + _res: EditableResponse, proxyRes: ProxyResponse, options: NormalizedServerOptions & { target: NormalizeProxyTarget }, ) { @@ -84,7 +91,7 @@ export function setRedirectHostRewrite( export function writeHeaders( _req: Request, // Response to set headers in - res: Response, + res: EditableResponse, // Response object from the proxy request proxyRes: ProxyResponse, // options.cookieDomainRewrite: Config to rewrite cookie domain @@ -147,7 +154,7 @@ export function writeHeaders( // Set the statusCode from the proxyResponse export function writeStatusCode( _req: Request, - res: Response, + res: EditableResponse, proxyRes: ProxyResponse, ) { // From Node.js docs: response.writeHead(statusCode[, statusMessage][, headers]) diff --git a/lib/http-proxy/passes/ws-incoming.ts b/lib/http-proxy/passes/ws-incoming.ts index fd99fa4..125361f 100644 --- a/lib/http-proxy/passes/ws-incoming.ts +++ b/lib/http-proxy/passes/ws-incoming.ts @@ -11,12 +11,14 @@ The names of passes are exported as WS_PASSES from this module. import * as http from "node:http"; import * as https from "node:https"; import * as common from "../common"; -import type { Request } from "./web-incoming"; +import type { Request, ProxyResponse } from "./web-incoming"; +import { OUTGOING_PASSES, EditableResponse } from "./web-outgoing"; import type { Socket } from "node:net"; import debug from "debug"; -import type { NormalizedServerOptions, ProxyServer } from ".."; +import type { NormalizedServerOptions, NormalizeProxyTarget, ProxyServer, ProxyTarget } from ".."; const log = debug("http-proxy-3:ws-incoming"); +const web_o = Object.values(OUTGOING_PASSES); function createSocketCounter(name: string) { let sockets = new Set(); @@ -55,6 +57,26 @@ function createSocketCounter(name: string) { const socketCounter = createSocketCounter("socket"); const proxySocketCounter = createSocketCounter("proxySocket"); +/* MockResponse + when a websocket gets a regular HTTP Response, + apply proxied headers +*/ +class MockResponse implements EditableResponse { + constructor() { + this.headers = {}; + this.statusCode = 200 + this.statusMessage = ""; + } + public headers: { [key: string]: string}; + public statusCode: number; + public statusMessage: string; + + setHeader(key: string, value: string) { + this.headers[key] = value; + return this; + }; +} + export function numOpenSockets(): number { return socketCounter() + proxySocketCounter(); } @@ -220,6 +242,57 @@ export function stream( socket.destroySoon(); } + // if we get a response, backend is not a websocket endpoint, + // relay HTTP response and close the socket + proxyReq.on("response", (proxyRes: ProxyResponse) => { + log("got non-ws HTTP response", + { + statusCode: proxyRes.statusCode, + statusMessage: proxyRes.statusMessage, + } + ); + + const res = new MockResponse(); + for (const pass of web_o) { + // note: none of these return anything + pass(req, res as EditableResponse, proxyRes, options as NormalizedServerOptions & { target: NormalizeProxyTarget }); + } + + // implement HTTP/1.1 chunked transfer unless content-length is defined + // matches proxyRes.pipe(res) behavior, + // but we are piping directly to the socket instead, so it's our job. + let writeChunk = (chunk: Buffer | string) => { + socket.write(chunk); + } + if (req.httpVersion === "1.1" && proxyRes.headers["content-length"] === undefined) { + res.headers["transfer-encoding"] = "chunked"; + writeChunk = (chunk: Buffer | string) => { + socket.write(chunk.length.toString(16)); + socket.write("\r\n"); + socket.write(chunk); + socket.write("\r\n"); + } + } + + const proxyHead = createHttpHeader( + `HTTP/${req.httpVersion} ${proxyRes.statusCode} ${proxyRes.statusMessage}`, + res.headers, + ); + if (!socket.destroyed) { + socket.write(proxyHead); + proxyRes.on("data", (chunk) => { + writeChunk(chunk); + }) + proxyRes.on("end", () => { + writeChunk(""); + socket.destroySoon(); + }) + } else { + // make sure response is consumed + proxyRes.resume(); + } + }); + proxyReq.end(); } diff --git a/lib/test/websocket/websocket-proxy-http.test.ts b/lib/test/websocket/websocket-proxy-http.test.ts new file mode 100644 index 0000000..525cdfa --- /dev/null +++ b/lib/test/websocket/websocket-proxy-http.test.ts @@ -0,0 +1,87 @@ +/* +pnpm test websocket-proxy-http.test.ts + +Test clients connecting a websocket to a non-websocket backend. +The connection should fail promptly and preserve the original error code. + +See https://nodejs.org/api/http.html#event-response + +DEVELOPMENT: + + pnpm test websocket-proxy-http.test.ts +*/ + +import * as http from "node:http"; +import * as httpProxy from "../.."; +import log from "../log"; +import getPort from "../get-port"; +import { once } from "node:events"; +import {describe, it, expect, beforeAll, afterAll} from "vitest"; + +describe("Example of client requesting websocket when backend is plain http", () => { + let ports: Record<'httpOnly' | 'proxy', number>; + beforeAll(async () => { + // assigns ports + ports = { httpOnly: await getPort(), proxy: await getPort() }; + }); + + let servers: any = {}; + + it("Create an http server that doesn't support websockets", async () => { + const server = http.createServer((_req, res) => { + res.writeHead(418, { "Content-Type": "text/plain" }); + res.end("not a websocket!"); + }); + + servers.httpOnly = server; + server.listen(ports.httpOnly); + }); + + it("Try a websocket client connecting to a regular HTTP backend", async () => { + const options = { + port: ports.httpOnly, + host: "localhost", + headers: { + Connection: "Upgrade", + Upgrade: "websocket", + }, + }; + const req = http.request(options); + req.end(); + const [res] = await once(req, "response"); + expect(res.statusCode).toEqual(418); + await once(res, "readable"); + const body = res.read().toString(); + expect(body.trim()).toEqual("not a websocket!"); + log("we got an http response."); + }); + + it("Create a proxy server pointed at the non-websocket server, expecting websockets", async () => { + servers.proxy = httpProxy + .createServer({ target: `ws://localhost:${ports.httpOnly}`, ws: true }) + .listen(ports.proxy); + }); + + it("Create a websocket client and test the proxy server", async () => { + const options = { + port: ports.proxy, + host: "localhost", + headers: { + Connection: "Upgrade", + Upgrade: "websocket", + }, + }; + const req = http.request(options); + req.end(); + const [res] = await once(req, "response"); + expect(res.statusCode).toEqual(418); + await once(res, "readable"); + const body = res.read().toString(); + expect(body.trim()).toEqual("not a websocket!"); + }); + + afterAll(async () => { + // cleans up + Object.values(servers).map((x: any) => x?.close()); + }); +});