diff --git a/src/common/utils/box.ts b/src/common/utils/box.ts new file mode 100644 index 0000000000..b7a63e9b29 --- /dev/null +++ b/src/common/utils/box.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export class Box { + constructor(private value: T) {} + + get = () => this.value; + + set = (newValue: T) => { + this.value = newValue; + }; +} diff --git a/src/main/lens-proxy/handle-lens-request.injectable.ts b/src/main/lens-proxy/handle-lens-request.injectable.ts index 6798283999..accbc4788b 100644 --- a/src/main/lens-proxy/handle-lens-request.injectable.ts +++ b/src/main/lens-proxy/handle-lens-request.injectable.ts @@ -5,14 +5,19 @@ import { getInjectable } from "@ogre-tools/injectable"; import assert from "assert"; import type { IncomingMessage, ServerResponse } from "http"; +import type { ErrorCallback, ProxyResCallback } from "http-proxy"; import HttpProxyServer from "http-proxy"; import { Socket } from "net"; +import type { Logger } from "../../common/logger"; import loggerInjectable from "../../common/logger.injectable"; +import { Box } from "../../common/utils/box"; import { apiKubePrefix } from "../../common/vars"; import contentSecurityPolicyInjectable from "../../common/vars/content-security-policy.injectable"; import type { ClusterContextHandler } from "../context-handler/context-handler"; +import type { RouteRequest } from "../router/route-request.injectable"; import routeRequestInjectable from "../router/route-request.injectable"; import { getBoolean } from "../utils/parse-query"; +import type { GetClusterForRequest } from "./get-cluster-for-request.injectable"; import getClusterForRequestInjectable from "./get-cluster-for-request.injectable"; const getRequestId = (req: IncomingMessage) => { @@ -37,6 +42,111 @@ export interface HandleLensRequest { stopHandling: () => void; } +const getProxyTarget = async (req: IncomingMessage, contextHandler: ClusterContextHandler) => { + if (req.url?.startsWith(apiKubePrefix)) { + delete req.headers.authorization; + req.url = req.url.replace(apiKubePrefix, ""); + + return contextHandler.getApiTarget(isLongRunningRequest(req.url)); + } + + return undefined; +}; + +const onProxyResWith = (retryCounters: Map): ProxyResCallback => (proxyRes, req, res) => { + retryCounters.delete(getRequestId(req)); + + proxyRes.on("aborted", () => { // happens when proxy target aborts connection + res.end(); + }); +}; + +interface OnErrorWithDeps { + closed: Box; + logger: Logger; + retryCounters: Map; + handleRequest: HandleRequest; +} + +const onErrorWith = (deps: OnErrorWithDeps): ErrorCallback => { + const { + closed, + logger, + retryCounters, + handleRequest, + } = deps; + + return (error, req, res, target) => { + if (closed.get() || res instanceof Socket) { + return; + } + + logger.error(`[LENS-PROXY]: http proxy errored for cluster: ${error}`, { url: req.url }); + + if (target) { + logger.debug(`Failed proxy to target: ${JSON.stringify(target, null, 2)}`); + + if (req.method === "GET" && (!res.statusCode || res.statusCode >= 500)) { + const reqId = getRequestId(req); + const retryCount = retryCounters.get(reqId) || 0; + const timeoutMs = retryCount * 250; + + if (retryCount < 20) { + logger.debug(`Retrying proxy request to url: ${reqId}`); + setTimeout(() => { + retryCounters.set(reqId, retryCount + 1); + + (async () => { + try { + await handleRequest(req, res); + } catch (error) { + logger.error(`[LENS-PROXY]: failed to handle request on proxy error: ${error}`); + } + })(); + }, timeoutMs); + } + } + } + + try { + res.writeHead(500).end(`Oops, something went wrong.\n${error}`); + } catch (e) { + logger.error(`[LENS-PROXY]: Failed to write headers: `, e); + } + }; +}; + +interface HandleRequestWithDeps { + getClusterForRequest: GetClusterForRequest; + proxy: HttpProxyServer; + contentSecurityPolicy: string; + routeRequest: RouteRequest; +} + +const handleRequestWith = (deps: HandleRequestWithDeps): HandleRequest => { + const { + getClusterForRequest, + proxy, + contentSecurityPolicy, + routeRequest, + } = deps; + + return async (req, res) => { + const cluster = getClusterForRequest(req); + + if (cluster) { + const proxyTarget = await getProxyTarget(req, cluster.contextHandler); + + if (proxyTarget) { + return proxy.web(req, res, proxyTarget); + } + } + + res.setHeader("Content-Security-Policy", contentSecurityPolicy); + await routeRequest(cluster, req, res); + }; +}; + const handleLensRequestInjectable = getInjectable({ id: "handle-lens-request", instantiate: (di): HandleLensRequest => { @@ -46,84 +156,27 @@ const handleLensRequestInjectable = getInjectable({ const routeRequest = di.inject(routeRequestInjectable); const retryCounters = new Map(); - let closed = false; + const closed = new Box(false); - const getProxyTarget = async (req: IncomingMessage, contextHandler: ClusterContextHandler) => { - if (req.url?.startsWith(apiKubePrefix)) { - delete req.headers.authorization; - req.url = req.url.replace(apiKubePrefix, ""); + const proxy = HttpProxyServer.createProxy(); + const handleRequest = handleRequestWith({ + contentSecurityPolicy, + getClusterForRequest, + proxy, + routeRequest, + }); - return contextHandler.getApiTarget(isLongRunningRequest(req.url)); - } - - return undefined; - }; - - const proxy = HttpProxyServer.createProxy() - .on("proxyRes", (proxyRes, req, res) => { - retryCounters.delete(getRequestId(req)); - - proxyRes.on("aborted", () => { // happens when proxy target aborts connection - res.end(); - }); - }) - .on("error", (error, req, res, target) => { - if (closed || res instanceof Socket) { - return; - } - - logger.error(`[LENS-PROXY]: http proxy errored for cluster: ${error}`, { url: req.url }); - - if (target) { - logger.debug(`Failed proxy to target: ${JSON.stringify(target, null, 2)}`); - - if (req.method === "GET" && (!res.statusCode || res.statusCode >= 500)) { - const reqId = getRequestId(req); - const retryCount = retryCounters.get(reqId) || 0; - const timeoutMs = retryCount * 250; - - if (retryCount < 20) { - logger.debug(`Retrying proxy request to url: ${reqId}`); - setTimeout(() => { - retryCounters.set(reqId, retryCount + 1); - - (async () => { - try { - await handleRequest(req, res); - } catch (error) { - logger.error(`[LENS-PROXY]: failed to handle request on proxy error: ${error}`); - } - })(); - }, timeoutMs); - } - } - } - - try { - res.writeHead(500).end(`Oops, something went wrong.\n${error}`); - } catch (e) { - logger.error(`[LENS-PROXY]: Failed to write headers: `, e); - } - }); - - const handleRequest: HandleRequest = async (req, res) => { - const cluster = getClusterForRequest(req); - - if (cluster) { - const proxyTarget = await getProxyTarget(req, cluster.contextHandler); - - if (proxyTarget) { - return proxy.web(req, res, proxyTarget); - } - } - - res.setHeader("Content-Security-Policy", contentSecurityPolicy); - await routeRequest(cluster, req, res); - }; + proxy.on("proxyRes", onProxyResWith(retryCounters)); + proxy.on("error", onErrorWith({ + closed, + handleRequest, + logger, + retryCounters, + })); return { handle: handleRequest, - stopHandling: () => closed = true, + stopHandling: () => closed.set(true), }; }, });