Path: blob/master/src/packages/hub/proxy/handle-upgrade.ts
1496 views
// Websocket support12import { createProxyServer, type ProxyServer } from "http-proxy-3";3import LRU from "lru-cache";4import { getEventListeners } from "node:events";5import getLogger from "@cocalc/hub/logger";6import stripRememberMeCookie from "./strip-remember-me-cookie";7import { getTarget } from "./target";8import { stripBasePath } from "./util";9import { versionCheckFails } from "./version";10import { proxyConatWebsocket } from "./proxy-conat";11import basePath from "@cocalc/backend/base-path";1213const LISTENERS_HACK = true;1415const logger = getLogger("proxy:handle-upgrade");1617export default function initUpgrade(18{ projectControl, isPersonal, httpServer, proxyConat },19proxy_regexp: string,20) {21const cache = new LRU<string, ProxyServer>({22max: 5000,23ttl: 1000 * 60 * 3,24});2526const re = new RegExp(proxy_regexp);2728let nextUpgrade: undefined | Function = undefined;29let socketioUpgrade: undefined | Function = undefined;3031async function handleProxyUpgradeRequest(req, socket, head): Promise<void> {32if (LISTENERS_HACK) {33const v = getEventListeners(httpServer, "upgrade");34if (v.length > 1) {35// Nodejs basically assumes that there is only one listener for the "upgrade" handler,36// but depending on how you run CoCalc, two others may get added:37// - a socketio server38// - a nextjs server39// We check if anything extra got added and if so, identify it and properly40// use it. We identify the handle using `${f}` and using a heuristic for the41// code. That's the best I can do and it's obviously brittle.42// Note: rspack for the static app doesn't use a websocket, instead using SSE, so43// fortunately it's not relevant and hmr works fine. HMR for the nextjs server44// tends to just refresh the page, probably because we're using rspack there too.45for (const f of v) {46if (f === handler) {47// it's us -- leave it alone48continue;49}50const source = `${f}`;51logger.debug(`found extra listener`, { f, source });52if (source.includes("destroyUpgrade")) {53// WARNING/BRITTLE! the socketio source code for the upgrade handler has a destroyUpgrade54// option it checks for, whereas the nextjs one doesn't.55if (socketioUpgrade === undefined) {56socketioUpgrade = f;57} else {58logger.debug(59"WARNING! discovered unknown upgrade listener!",60source,61);62}63} else {64if (nextUpgrade === undefined) {65nextUpgrade = f;66} else {67logger.debug(68"WARNING! discovered unknown upgrade listener!",69source,70);71}72}73logger.debug(74`found extra listener -- detected, saved and removed 'upgrade' listener`,75source,76);77httpServer.removeListener("upgrade", f);78}79}80}8182if (proxyConat && useSocketio(req.url)) {83proxyConatWebsocket(req, socket, head);84return;85}8687if (!req.url.match(re)) {88// it's to be handled by socketio or next89if (socketioUpgrade !== undefined && useSocketio(req.url)) {90socketioUpgrade(req, socket, head);91return;92}93nextUpgrade?.(req, socket, head);94return;95}9697socket.on("error", (err) => {98// server will crash sometimes without this:99logger.debug("WARNING -- websocket socket error", err);100});101102const dbg = (...args) => {103logger.silly(req.url, ...args);104};105dbg("got upgrade request from url=", req.url);106107// Check that minimum version requirement is satisfied (this is in the header).108// This is to have a way to stop buggy clients from causing trouble. It's a purely109// honor system sort of thing, but makes it possible for an admin to block clients110// until they run newer code. I used to have to use this a lot long ago...111if (versionCheckFails(req)) {112throw Error("client version check failed");113}114115let remember_me, api_key;116if (req.headers["cookie"] != null) {117let cookie;118({ cookie, remember_me, api_key } = stripRememberMeCookie(119req.headers["cookie"],120));121req.headers["cookie"] = cookie;122}123124dbg("calling getTarget");125const url = stripBasePath(req.url);126const { host, port, internal_url } = await getTarget({127url,128isPersonal,129projectControl,130remember_me,131api_key,132});133dbg("got ", { host, port });134135const target = `ws://${host}:${port}`;136if (internal_url != null) {137req.url = internal_url;138}139140{141const proxy = cache.get(target);142if (proxy != null) {143dbg("using cache");144proxy.ws(req, socket, head);145return;146}147}148149dbg("target", target);150dbg("not using cache");151152const proxy = createProxyServer({153ws: true,154target,155});156157cache.set(target, proxy);158159// taken from https://github.com/http-party/node-http-proxy/issues/1401160proxy.on("proxyRes", function (proxyRes) {161//console.log(162// "Raw [target] response",163// JSON.stringify(proxyRes.headers, true, 2)164//);165166proxyRes.headers["x-reverse-proxy"] = "custom-proxy";167proxyRes.headers["cache-control"] = "no-cache, no-store";168169//console.log(170// "Updated [proxied] response",171// JSON.stringify(proxyRes.headers, true, 2)172//);173});174175proxy.on("error", (err) => {176logger.debug(`WARNING: websocket proxy error -- ${err}`);177});178179proxy.ws(req, socket, head);180}181182const handler = async (req, socket, head) => {183try {184await handleProxyUpgradeRequest(req, socket, head);185} catch (err) {186const msg = `WARNING: error upgrading websocket url=${req.url} -- ${err}`;187logger.debug(msg);188denyUpgrade(socket);189}190};191192return handler;193}194195function denyUpgrade(socket) {196socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");197socket.destroy();198}199200function useSocketio(url: string) {201const u = new URL(url, "http://cocalc.com");202let pathname = u.pathname;203if (basePath.length > 1) {204pathname = pathname.slice(basePath.length);205}206return pathname == "/conat/";207}208209210