From aea545526d87945ceec81138a3d8b13d70710992 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Thu, 12 Jan 2023 14:39:13 -0500 Subject: [PATCH] Make the upgrade routes actually use a router Signed-off-by: Sebastian Malton --- src/common/utils/__tests__/chunk.test.ts | 61 +++++++++++++++ src/common/utils/chunk.ts | 36 +++++++++ .../get-cluster-for-request.injectable.ts | 5 +- src/main/lens-proxy/lens-proxy.injectable.ts | 34 +------- src/main/lens-proxy/proxy-functions/index.ts | 6 -- .../kube-api-upgrade-request.ts | 66 ---------------- .../shell-api-request.injectable.ts | 42 ---------- .../shell-request-authenticator.injectable.ts | 20 ----- .../shell-request-authenticator.ts | 53 ------------- src/main/lens-proxy/proxy-functions/types.ts | 16 ---- .../kube-api-upgrade-route.injectable.ts | 78 +++++++++++++++++++ .../upgrade-router/proxy-upgrade-route.ts | 29 +++++++ .../upgrade-router/router.injectable.ts | 56 +++++++++++++ .../shell-api-request.injectable.ts | 47 +++++++++++ .../shell-request-authenticator.injectable.ts | 52 +++++++++++++ src/main/router/route-request.injectable.ts | 4 +- 16 files changed, 367 insertions(+), 238 deletions(-) create mode 100644 src/common/utils/__tests__/chunk.test.ts create mode 100644 src/common/utils/chunk.ts delete mode 100644 src/main/lens-proxy/proxy-functions/index.ts delete mode 100644 src/main/lens-proxy/proxy-functions/kube-api-upgrade-request.ts delete mode 100644 src/main/lens-proxy/proxy-functions/shell-api-request.injectable.ts delete mode 100644 src/main/lens-proxy/proxy-functions/shell-request-authenticator/shell-request-authenticator.injectable.ts delete mode 100644 src/main/lens-proxy/proxy-functions/shell-request-authenticator/shell-request-authenticator.ts delete mode 100644 src/main/lens-proxy/proxy-functions/types.ts create mode 100644 src/main/lens-proxy/upgrade-router/kube-api-upgrade-route.injectable.ts create mode 100644 src/main/lens-proxy/upgrade-router/proxy-upgrade-route.ts create mode 100644 src/main/lens-proxy/upgrade-router/router.injectable.ts create mode 100644 src/main/lens-proxy/upgrade-router/shell-api-request.injectable.ts create mode 100644 src/main/lens-proxy/upgrade-router/shell-request-authenticator.injectable.ts diff --git a/src/common/utils/__tests__/chunk.test.ts b/src/common/utils/__tests__/chunk.test.ts new file mode 100644 index 0000000000..4179bed140 --- /dev/null +++ b/src/common/utils/__tests__/chunk.test.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { chunkSkipEnd } from "../chunk"; + +describe("chunkSkipEnd", () => { + it("should yield no elements when given an empty iterator", () => { + const i = chunkSkipEnd([], 2); + + expect(i.next()).toEqual({ done: true }); + }); + + it("should yield no elements when given an iterator of size less than chunk=2", () => { + const i = chunkSkipEnd([1], 2); + + expect(i.next()).toEqual({ done: true }); + }); + + it("should yield no elements when given an chunk=0", () => { + const i = chunkSkipEnd([1], 0); + + expect(i.next()).toEqual({ done: true }); + }); + + it("should yield no elements when given an chunk<0", () => { + const i = chunkSkipEnd([1], 0); + + expect(i.next()).toEqual({ done: true }); + }); + + it("should yield no elements when given an iterator of size less than chunk=3", () => { + const i = chunkSkipEnd([1, 2], 3); + + expect(i.next()).toEqual({ done: true }); + }); + + it("should yield one chunk when given an iterator of size equal to chunk", () => { + const i = chunkSkipEnd([1, 2], 2); + + expect(i.next()).toEqual({ done: false, value: [1, 2] }); + expect(i.next()).toEqual({ done: true }); + }); + + it("should yield two chunks when given an iterator of size equal to chunk*2", () => { + const i = chunkSkipEnd([1, 2, 3, 4], 2); + + expect(i.next()).toEqual({ done: false, value: [1, 2] }); + expect(i.next()).toEqual({ done: false, value: [3, 4] }); + expect(i.next()).toEqual({ done: true }); + }); + + it("should yield two chunks when given an iterator of size between chunk*2 and chunk*3", () => { + const i = chunkSkipEnd([1, 2, 3, 4, 5], 2); + + expect(i.next()).toEqual({ done: false, value: [1, 2] }); + expect(i.next()).toEqual({ done: false, value: [3, 4] }); + expect(i.next()).toEqual({ done: true }); + }); +}); diff --git a/src/common/utils/chunk.ts b/src/common/utils/chunk.ts new file mode 100644 index 0000000000..138b3ed76e --- /dev/null +++ b/src/common/utils/chunk.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { Tuple } from "./tuple"; + +/** + * Will yield consecutive chunks of `size` length. If `src.length` is not a multiple of `size` + * then the final values will be iterated over but not yielded. + * @param src The original array + * @param size The size of the chunks + */ +export function* chunkSkipEnd(src: Iterable, size: Size): IterableIterator> { + if (size <= 0) { + return; + } + + const iter = src[Symbol.iterator](); + + for (;;) { + const chunk = []; + + for (let i = 0; i < size; i += 1) { + const result = iter.next(); + + if (result.done === true) { + return; + } + + chunk.push(result.value); + } + + yield chunk as Tuple; + } +} diff --git a/src/main/lens-proxy/get-cluster-for-request.injectable.ts b/src/main/lens-proxy/get-cluster-for-request.injectable.ts index 7d529540d1..b418c95d00 100644 --- a/src/main/lens-proxy/get-cluster-for-request.injectable.ts +++ b/src/main/lens-proxy/get-cluster-for-request.injectable.ts @@ -6,7 +6,10 @@ import { getInjectable } from "@ogre-tools/injectable"; import getClusterByIdInjectable from "../../common/cluster-store/get-by-id.injectable"; import { getClusterIdFromHost } from "../../common/utils"; import { apiKubePrefix } from "../../common/vars"; -import type { GetClusterForRequest } from "./lens-proxy"; +import type { IncomingMessage } from "http"; +import type { Cluster } from "../../common/cluster/cluster"; + +export type GetClusterForRequest = (req: IncomingMessage) => Cluster | undefined; const getClusterForRequestInjectable = getInjectable({ id: "get-cluster-for-request", diff --git a/src/main/lens-proxy/lens-proxy.injectable.ts b/src/main/lens-proxy/lens-proxy.injectable.ts index 2bc2acef99..e035edca3e 100644 --- a/src/main/lens-proxy/lens-proxy.injectable.ts +++ b/src/main/lens-proxy/lens-proxy.injectable.ts @@ -3,23 +3,14 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import type { ProxyApiRequestArgs } from "./proxy-functions"; -import { kubeApiUpgradeRequest } from "./proxy-functions"; -import shellApiRequestInjectable from "./proxy-functions/shell-api-request.injectable"; import lensProxyPortInjectable from "./lens-proxy-port.injectable"; import emitAppEventInjectable from "../../common/app-event-bus/emit-event.injectable"; import loggerInjectable from "../../common/logger.injectable"; import lensProxyCertificateInjectable from "../../common/certificate/lens-proxy-certificate.injectable"; -import getClusterForRequestInjectable from "./get-cluster-for-request.injectable"; -import type { IncomingMessage } from "http"; import type net from "net"; -import type { Cluster } from "../../common/cluster/cluster"; -import { apiPrefix } from "../../common/vars"; import { createServer } from "https"; import handleLensRequestInjectable from "./handle-lens-request.injectable"; - -export type GetClusterForRequest = (req: IncomingMessage) => Cluster | undefined; -export type LensProxyApiRequest = (args: ProxyApiRequestArgs) => void | Promise; +import routeUpgradeRequestInjectable from "./upgrade-router/router.injectable"; export interface LensProxy { listen: () => Promise; @@ -48,13 +39,12 @@ const lensProxyInjectable = getInjectable({ id: "lens-proxy", instantiate: (di): LensProxy => { - const shellApiRequest = di.inject(shellApiRequestInjectable); - const getClusterForRequest = di.inject(getClusterForRequestInjectable); const lensProxyPort = di.inject(lensProxyPortInjectable); const emitAppEvent = di.inject(emitAppEventInjectable); const logger = di.inject(loggerInjectable); const certificate = di.inject(lensProxyCertificateInjectable).get(); const handleLensRequest = di.inject(handleLensRequestInjectable); + const routeUpgradeRequest = di.inject(routeUpgradeRequestInjectable); const proxyServer = createServer( { @@ -63,25 +53,7 @@ const lensProxyInjectable = getInjectable({ }, handleLensRequest.handle, ) - .on("upgrade", (req, socket, head) => { - const cluster = getClusterForRequest(req); - - if (!cluster || !req.url) { - logger.error(`[LENS-PROXY]: Could not find cluster for upgrade request from url=${req.url}`); - socket.destroy(); - } else { - const isInternal = req.url.startsWith(`${apiPrefix}?`); - const reqHandler = isInternal ? shellApiRequest : kubeApiUpgradeRequest; - - (async () => { - try { - await reqHandler({ req, socket, head, cluster }); - } catch (error) { - logger.error("[LENS-PROXY]: failed to handle proxy upgrade", error); - } - })(); - } - }); + .on("upgrade", routeUpgradeRequest); const attemptToListen = () => new Promise((resolve, reject) => { proxyServer.listen(0, "127.0.0.1"); diff --git a/src/main/lens-proxy/proxy-functions/index.ts b/src/main/lens-proxy/proxy-functions/index.ts deleted file mode 100644 index 5d374825ee..0000000000 --- a/src/main/lens-proxy/proxy-functions/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -export * from "./kube-api-upgrade-request"; -export * from "./types"; diff --git a/src/main/lens-proxy/proxy-functions/kube-api-upgrade-request.ts b/src/main/lens-proxy/proxy-functions/kube-api-upgrade-request.ts deleted file mode 100644 index ddd8e66261..0000000000 --- a/src/main/lens-proxy/proxy-functions/kube-api-upgrade-request.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { chunk } from "lodash"; -import type { ConnectionOptions } from "tls"; -import { connect } from "tls"; -import url from "url"; -import { apiKubePrefix } from "../../../common/vars"; -import type { ProxyApiRequestArgs } from "./types"; - -const skipRawHeaders = new Set(["Host", "Authorization"]); - -export async function kubeApiUpgradeRequest({ req, socket, head, cluster }: ProxyApiRequestArgs) { - const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, ""); - const proxyCa = cluster.contextHandler.resolveAuthProxyCa(); - const apiUrl = url.parse(cluster.apiUrl); - const pUrl = url.parse(proxyUrl); - const connectOpts: ConnectionOptions = { - port: pUrl.port ? parseInt(pUrl.port) : undefined, - host: pUrl.hostname ?? undefined, - ca: proxyCa, - }; - - const proxySocket = connect(connectOpts, () => { - proxySocket.write(`${req.method} ${pUrl.path} HTTP/1.1\r\n`); - proxySocket.write(`Host: ${apiUrl.host}\r\n`); - - for (const [key, value] of chunk(req.rawHeaders, 2)) { - if (skipRawHeaders.has(key)) { - continue; - } - - proxySocket.write(`${key}: ${value}\r\n`); - } - - proxySocket.write("\r\n"); - proxySocket.write(head); - }); - - proxySocket.setKeepAlive(true); - socket.setKeepAlive(true); - proxySocket.setTimeout(0); - socket.setTimeout(0); - - proxySocket.on("data", function (chunk) { - socket.write(chunk); - }); - proxySocket.on("end", function () { - socket.end(); - }); - proxySocket.on("error", function () { - socket.write(`HTTP/${req.httpVersion} 500 Connection error\r\n\r\n`); - socket.end(); - }); - socket.on("data", function (chunk) { - proxySocket.write(chunk); - }); - socket.on("end", function () { - proxySocket.end(); - }); - socket.on("error", function () { - proxySocket.end(); - }); -} diff --git a/src/main/lens-proxy/proxy-functions/shell-api-request.injectable.ts b/src/main/lens-proxy/proxy-functions/shell-api-request.injectable.ts deleted file mode 100644 index afaf2a870d..0000000000 --- a/src/main/lens-proxy/proxy-functions/shell-api-request.injectable.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import shellRequestAuthenticatorInjectable from "./shell-request-authenticator/shell-request-authenticator.injectable"; -import openShellSessionInjectable from "../../shell-session/create-shell-session.injectable"; -import type { LensProxyApiRequest } from "../lens-proxy"; -import URLParse from "url-parse"; -import { Server as WebSocketServer } from "ws"; -import loggerInjectable from "../../../common/logger.injectable"; -import getClusterForRequestInjectable from "../get-cluster-for-request.injectable"; - -const shellApiRequestInjectable = getInjectable({ - id: "shell-api-request", - - instantiate: (di): LensProxyApiRequest => { - const openShellSession = di.inject(openShellSessionInjectable); - const authenticateRequest = di.inject(shellRequestAuthenticatorInjectable).authenticate; - const getClusterForRequest = di.inject(getClusterForRequestInjectable); - const logger = di.inject(loggerInjectable); - - return ({ req, socket, head }) => { - const cluster = getClusterForRequest(req); - const { query: { node: nodeName, shellToken, id: tabId }} = new URLParse(req.url, true); - - if (!tabId || !cluster || !authenticateRequest(cluster.id, tabId, shellToken)) { - socket.write("Invalid shell request"); - socket.end(); - } else { - const ws = new WebSocketServer({ noServer: true }); - - ws.handleUpgrade(req, socket, head, (websocket) => { - openShellSession({ websocket, cluster, tabId, nodeName }) - .catch(error => logger.error(`[SHELL-SESSION]: failed to open a ${nodeName ? "node" : "local"} shell`, error)); - }); - } - }; - }, -}); - -export default shellApiRequestInjectable; diff --git a/src/main/lens-proxy/proxy-functions/shell-request-authenticator/shell-request-authenticator.injectable.ts b/src/main/lens-proxy/proxy-functions/shell-request-authenticator/shell-request-authenticator.injectable.ts deleted file mode 100644 index c273f105d0..0000000000 --- a/src/main/lens-proxy/proxy-functions/shell-request-authenticator/shell-request-authenticator.injectable.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import { ShellRequestAuthenticator } from "./shell-request-authenticator"; - -const shellRequestAuthenticatorInjectable = getInjectable({ - id: "shell-request-authenticator", - - instantiate: () => { - const authenticator = new ShellRequestAuthenticator(); - - authenticator.init(); - - return authenticator; - }, -}); - -export default shellRequestAuthenticatorInjectable; diff --git a/src/main/lens-proxy/proxy-functions/shell-request-authenticator/shell-request-authenticator.ts b/src/main/lens-proxy/proxy-functions/shell-request-authenticator/shell-request-authenticator.ts deleted file mode 100644 index ab5e46eb77..0000000000 --- a/src/main/lens-proxy/proxy-functions/shell-request-authenticator/shell-request-authenticator.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getOrInsertMap } from "../../../../common/utils"; -import type { ClusterId } from "../../../../common/cluster-types"; -import { ipcMainHandle } from "../../../../common/ipc"; -import crypto from "crypto"; -import { promisify } from "util"; - -const randomBytes = promisify(crypto.randomBytes); - -export class ShellRequestAuthenticator { - private tokens = new Map>(); - - init() { - ipcMainHandle("cluster:shell-api", async (event, clusterId, tabId) => { - const authToken = Uint8Array.from(await randomBytes(128)); - const forCluster = getOrInsertMap(this.tokens, clusterId); - - forCluster.set(tabId, authToken); - - return authToken; - }); - } - - /** - * Authenticates a single use token for creating a new shell - * @param clusterId The `ClusterId` for the shell - * @param tabId The ID for the shell - * @param token The value that is being presented as a one time authentication token - * @returns `true` if `token` was valid, false otherwise - */ - authenticate = (clusterId: ClusterId, tabId: string, token: string | undefined): boolean => { - const clusterTokens = this.tokens.get(clusterId); - - if (!clusterTokens || !tabId || !token) { - return false; - } - - const authToken = clusterTokens.get(tabId); - const buf = Uint8Array.from(Buffer.from(token, "base64")); - - if (authToken instanceof Uint8Array && authToken.length === buf.length && crypto.timingSafeEqual(authToken, buf)) { - // remove the token because it is a single use token - clusterTokens.delete(tabId); - - return true; - } - - return false; - }; -} diff --git a/src/main/lens-proxy/proxy-functions/types.ts b/src/main/lens-proxy/proxy-functions/types.ts deleted file mode 100644 index b6592ad244..0000000000 --- a/src/main/lens-proxy/proxy-functions/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type http from "http"; -import type net from "net"; -import type { SetRequired } from "type-fest"; -import type { Cluster } from "../../../common/cluster/cluster"; - -export interface ProxyApiRequestArgs { - req: SetRequired; - socket: net.Socket; - head: Buffer; - cluster: Cluster; -} diff --git a/src/main/lens-proxy/upgrade-router/kube-api-upgrade-route.injectable.ts b/src/main/lens-proxy/upgrade-router/kube-api-upgrade-route.injectable.ts new file mode 100644 index 0000000000..cf2b899df7 --- /dev/null +++ b/src/main/lens-proxy/upgrade-router/kube-api-upgrade-route.injectable.ts @@ -0,0 +1,78 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ConnectionOptions } from "tls"; +import { connect } from "tls"; +import url from "url"; +import { chunkSkipEnd } from "../../../common/utils/chunk"; +import { apiKubePrefix } from "../../../common/vars"; + +const skipRawHeaders = new Set(["host", "authorization"]); + +import { getInjectable } from "@ogre-tools/injectable"; +import { lensProxyUpgradeRouteInjectionToken } from "./proxy-upgrade-route"; + +const kubeApiUpgradeRouteInjectable = getInjectable({ + id: "kube-api-upgrade-route", + instantiate: () => ({ + handler: async ({ req, socket, head, cluster }) => { + const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, ""); + const proxyCa = cluster.contextHandler.resolveAuthProxyCa(); + const apiUrl = url.parse(cluster.apiUrl); + const pUrl = url.parse(proxyUrl); + const connectOpts: ConnectionOptions = { + port: pUrl.port ? parseInt(pUrl.port) : undefined, + host: pUrl.hostname ?? undefined, + ca: proxyCa, + }; + + const proxySocket = connect(connectOpts, () => { + proxySocket.write(`${req.method} ${pUrl.path} HTTP/1.1\r\n`); + proxySocket.write(`Host: ${apiUrl.host}\r\n`); + + for (const [key, value] of chunkSkipEnd(req.rawHeaders, 2)) { + if (skipRawHeaders.has(key.toLowerCase())) { + continue; + } + + proxySocket.write(`${key}: ${value}\r\n`); + } + + proxySocket.write("\r\n"); + proxySocket.write(head); + }); + + proxySocket.setKeepAlive(true); + socket.setKeepAlive(true); + proxySocket.setTimeout(0); + socket.setTimeout(0); + + proxySocket.on("data", function (chunk) { + socket.write(chunk); + }); + proxySocket.on("end", function () { + socket.end(); + }); + proxySocket.on("error", function () { + socket.write(`HTTP/${req.httpVersion} 500 Connection error\r\n\r\n`); + socket.end(); + }); + socket.on("data", function (chunk) { + proxySocket.write(chunk); + }); + socket.on("end", function () { + proxySocket.end(); + }); + socket.on("error", function () { + proxySocket.end(); + }); + }, + path: `${apiKubePrefix}/*`, + }), + injectionToken: lensProxyUpgradeRouteInjectionToken, +}); + +export default kubeApiUpgradeRouteInjectable; + diff --git a/src/main/lens-proxy/upgrade-router/proxy-upgrade-route.ts b/src/main/lens-proxy/upgrade-router/proxy-upgrade-route.ts new file mode 100644 index 0000000000..a4ca69a5d8 --- /dev/null +++ b/src/main/lens-proxy/upgrade-router/proxy-upgrade-route.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { SetRequired } from "type-fest"; +import type { IncomingMessage } from "http"; +import type { Cluster } from "../../../common/cluster/cluster"; +import type { Socket } from "net"; + +export type LensProxyRequest = SetRequired; + +export interface LensProxyUpgradeRequestArgs { + req: LensProxyRequest; + socket: Socket; + head: Buffer; + cluster: Cluster; +} +export type LensProxyUpgradeRequestHandler = (args: LensProxyUpgradeRequestArgs) => void | Promise; + +export interface LensProxyUpgradeRoute { + path: string; + handler: LensProxyUpgradeRequestHandler; +} + +export const lensProxyUpgradeRouteInjectionToken = getInjectionToken({ + id: "lens-proxy-upgrade-route-token", +}); diff --git a/src/main/lens-proxy/upgrade-router/router.injectable.ts b/src/main/lens-proxy/upgrade-router/router.injectable.ts new file mode 100644 index 0000000000..9c95bbcba7 --- /dev/null +++ b/src/main/lens-proxy/upgrade-router/router.injectable.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { Router } from "@hapi/call"; +import { getInjectable } from "@ogre-tools/injectable"; +import type { Socket } from "net"; +import loggerInjectable from "../../../common/logger.injectable"; +import getClusterForRequestInjectable from "../get-cluster-for-request.injectable"; +import type { LensProxyRequest, LensProxyUpgradeRequestHandler } from "./proxy-upgrade-route"; +import { lensProxyUpgradeRouteInjectionToken } from "./proxy-upgrade-route"; + +export type RouteUpgradeRequest = (req: LensProxyRequest, socket: Socket, head: Buffer) => Promise; + +const routeUpgradeRequestInjectable = getInjectable({ + id: "route-upgrade-request", + instantiate: (di): RouteUpgradeRequest => { + const routes = di.injectMany(lensProxyUpgradeRouteInjectionToken); + const logger = di.inject(loggerInjectable); + const getClusterForRequest = di.inject(getClusterForRequestInjectable); + + const router = new Router(); + + for (const route of routes) { + router.add({ method: "get", path: route.path }, route.handler); + } + + return async (req, socket, head) => { + const cluster = getClusterForRequest(req); + const url = new URL(req.url, "https://localhost"); + + if (!cluster) { + logger.error(`[LENS-PROXY]: Could not find cluster for upgrade request from url=${req.url}`); + socket.destroy(); + + return; + } + + const matchingRoute = router.route("get", url.pathname); + + if (matchingRoute instanceof Error) { + logger.warn(`[LENS-PROXY]: no matching upgrade route found for url=${req.url}`); + + return; + } + + try { + await matchingRoute.route({ cluster, head, req, socket }); + } catch (error) { + logger.error("[LENS-PROXY]: failed to handle proxy upgrade", error); + } + }; + }, +}); + +export default routeUpgradeRequestInjectable; diff --git a/src/main/lens-proxy/upgrade-router/shell-api-request.injectable.ts b/src/main/lens-proxy/upgrade-router/shell-api-request.injectable.ts new file mode 100644 index 0000000000..226f697b9c --- /dev/null +++ b/src/main/lens-proxy/upgrade-router/shell-api-request.injectable.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import authenticateShellRequestInjectable from "./shell-request-authenticator.injectable"; +import openShellSessionInjectable from "../../shell-session/create-shell-session.injectable"; +import URLParse from "url-parse"; +import { Server as WebSocketServer } from "ws"; +import loggerInjectable from "../../../common/logger.injectable"; +import getClusterForRequestInjectable from "../get-cluster-for-request.injectable"; +import { lensProxyUpgradeRouteInjectionToken } from "./proxy-upgrade-route"; +import { apiPrefix } from "../../../common/vars"; + +const shellApiUpgradeRouteInjectable = getInjectable({ + id: "shell-api-request", + + instantiate: (di) => { + const openShellSession = di.inject(openShellSessionInjectable); + const authenticateShellRequest = di.inject(authenticateShellRequestInjectable); + const getClusterForRequest = di.inject(getClusterForRequestInjectable); + const logger = di.inject(loggerInjectable); + + return { + handler: ({ req, socket, head }) => { + const cluster = getClusterForRequest(req); + const { query: { node: nodeName, shellToken, id: tabId }} = new URLParse(req.url, true); + + if (!tabId || !cluster || !authenticateShellRequest(cluster.id, tabId, shellToken)) { + socket.write("Invalid shell request"); + socket.end(); + } else { + const ws = new WebSocketServer({ noServer: true }); + + ws.handleUpgrade(req, socket, head, (websocket) => { + openShellSession({ websocket, cluster, tabId, nodeName }) + .catch(error => logger.error(`[SHELL-SESSION]: failed to open a ${nodeName ? "node" : "local"} shell`, error)); + }); + } + }, + path: apiPrefix, + }; + }, + injectionToken: lensProxyUpgradeRouteInjectionToken, +}); + +export default shellApiUpgradeRouteInjectable; diff --git a/src/main/lens-proxy/upgrade-router/shell-request-authenticator.injectable.ts b/src/main/lens-proxy/upgrade-router/shell-request-authenticator.injectable.ts new file mode 100644 index 0000000000..519d162d07 --- /dev/null +++ b/src/main/lens-proxy/upgrade-router/shell-request-authenticator.injectable.ts @@ -0,0 +1,52 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { ClusterId } from "../../../common/cluster-types"; +import { ipcMainHandle } from "../../../common/ipc"; +import { getOrInsertMap } from "../../../common/utils"; +import randomBytesInjectable from "../../../common/utils/random-bytes.injectable"; +import crypto from "crypto"; + +export type AuthenticateShellRequest = (clusterId: ClusterId, tabId: string, token: string | undefined) => boolean; + +const authenticateShellRequestInjectable = getInjectable({ + id: "authenticate-shell-request", + + instantiate: (di): AuthenticateShellRequest => { + const randomBytes = di.inject(randomBytesInjectable); + const tokens = new Map>(); + + ipcMainHandle("cluster:shell-api", async (event, clusterId, tabId) => { + const authToken = Uint8Array.from(await randomBytes(128)); + const forCluster = getOrInsertMap(tokens, clusterId); + + forCluster.set(tabId, authToken); + + return authToken; + }); + + return (clusterId, tabId, token) => { + const clusterTokens = tokens.get(clusterId); + + if (!clusterTokens || !tabId || !token) { + return false; + } + + const authToken = clusterTokens.get(tabId); + const buf = Uint8Array.from(Buffer.from(token, "base64")); + + if (authToken instanceof Uint8Array && authToken.length === buf.length && crypto.timingSafeEqual(authToken, buf)) { + // remove the token because it is a single use token + clusterTokens.delete(tabId); + + return true; + } + + return false; + }; + }, +}); + +export default authenticateShellRequestInjectable; diff --git a/src/main/router/route-request.injectable.ts b/src/main/router/route-request.injectable.ts index 2af812f82e..9af8381638 100644 --- a/src/main/router/route-request.injectable.ts +++ b/src/main/router/route-request.injectable.ts @@ -50,9 +50,7 @@ const routeRequestInjectable = getInjectable({ } const url = new URL(req.url, "https://localhost"); - const path = url.pathname; - const method = req.method.toLowerCase(); - const matchingRoute = router.route(method, path); + const matchingRoute = router.route(req.method, url.pathname); if (matchingRoute instanceof Error) { return false;