diff --git a/src/features/terminal/common/shell-api-auth-channel.ts b/src/features/terminal/common/shell-api-auth-channel.ts new file mode 100644 index 0000000000..3b0ca6f49e --- /dev/null +++ b/src/features/terminal/common/shell-api-auth-channel.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { RequestChannel } from "../../../common/utils/channel/request-channel-listener-injection-token"; + +export interface ShellApiAuthArgs { + clusterId: string; + tabId: string; +} + +export type ShellApiAuthChannel = RequestChannel; + +export const shellApiAuthChannel: ShellApiAuthChannel = { + id: "cluster-shell-api-auth", +}; diff --git a/src/features/terminal/main/shell-api-auth-listener.injectable.ts b/src/features/terminal/main/shell-api-auth-listener.injectable.ts new file mode 100644 index 0000000000..eccab7c3c6 --- /dev/null +++ b/src/features/terminal/main/shell-api-auth-listener.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getRequestChannelListenerInjectable } from "../../../main/utils/channel/channel-listeners/listener-tokens"; +import { shellApiAuthChannel } from "../common/shell-api-auth-channel"; +import shellApiAuthenticatorInjectable from "./shell-api-authenticator.injectable"; + +const shellApiAuthRequestChannelListener = getRequestChannelListenerInjectable({ + channel: shellApiAuthChannel, + handler: (di) => { + const shellApiAuthenticator = di.inject(shellApiAuthenticatorInjectable); + + return ({ clusterId, tabId }) => shellApiAuthenticator.requestToken(clusterId, tabId); + }, +}); + +export default shellApiAuthRequestChannelListener; diff --git a/src/features/terminal/main/shell-api-authenticator.injectable.ts b/src/features/terminal/main/shell-api-authenticator.injectable.ts new file mode 100644 index 0000000000..7588730caf --- /dev/null +++ b/src/features/terminal/main/shell-api-authenticator.injectable.ts @@ -0,0 +1,55 @@ +/** + * 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 { getOrInsertMap, put } from "../../../common/utils"; +import type { TabId } from "../../../renderer/components/dock/dock/store"; +import crypto from "crypto"; +import { promisify } from "util"; + +const randomBytes = promisify(crypto.randomBytes); + +export interface ShellApiAuthenticator { + authenticate: (clusterId: ClusterId, tabId: string, token: string | null) => boolean; + requestToken: (clusterId: ClusterId, tabId: TabId) => Promise; +} + +const shellApiAuthenticatorInjectable = getInjectable({ + id: "shell-api-authenticator", + instantiate: (): ShellApiAuthenticator => { + const tokens = new Map>(); + + return { + authenticate: (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; + }, + requestToken: async (clusterId, tabId) => ( + put( + getOrInsertMap(tokens, clusterId), + tabId, + Uint8Array.from(await randomBytes(128)), + ) + ), + }; + }, +}); + +export default shellApiAuthenticatorInjectable; 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 index afaf2a870d..04b222554d 100644 --- a/src/main/lens-proxy/proxy-functions/shell-api-request.injectable.ts +++ b/src/main/lens-proxy/proxy-functions/shell-api-request.injectable.ts @@ -3,38 +3,44 @@ * 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 { URL } from "url"; +import { WebSocketServer } from "ws"; import loggerInjectable from "../../../common/logger.injectable"; +import shellApiAuthenticatorInjectable from "../../../features/terminal/main/shell-api-authenticator.injectable"; +import clusterManagerInjectable from "../../cluster/manager.injectable"; +import openShellSessionInjectable from "../../shell-session/create-shell-session.injectable"; import getClusterForRequestInjectable from "../get-cluster-for-request.injectable"; +import type { ProxyApiRequestArgs } from "./types"; const shellApiRequestInjectable = getInjectable({ id: "shell-api-request", - instantiate: (di): LensProxyApiRequest => { + instantiate: (di) => { const openShellSession = di.inject(openShellSessionInjectable); - const authenticateRequest = di.inject(shellRequestAuthenticatorInjectable).authenticate; - const getClusterForRequest = di.inject(getClusterForRequestInjectable); + const clusterManager = di.inject(clusterManagerInjectable); const logger = di.inject(loggerInjectable); + const shellApiAuthenticator = di.inject(shellApiAuthenticatorInjectable); + const getClusterForRequest = di.inject(getClusterForRequestInjectable); - return ({ req, socket, head }) => { + return ({ req, socket, head }: ProxyApiRequestArgs): void => { const cluster = getClusterForRequest(req); - const { query: { node: nodeName, shellToken, id: tabId }} = new URLParse(req.url, true); + const { searchParams } = new URL(req.url); + const tabId = searchParams.get("id"); + const nodeName = searchParams.get("node"); + const shellToken = searchParams.get("shellToken"); - if (!tabId || !cluster || !authenticateRequest(cluster.id, tabId, shellToken)) { + if (!tabId || !cluster || !shellApiAuthenticator.authenticate(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)); - }); + return void socket.end(); } + + 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)); + }); }; }, }); 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/shell-session/create-shell-session.injectable.ts b/src/main/shell-session/create-shell-session.injectable.ts index 6af65dc908..d143939e69 100644 --- a/src/main/shell-session/create-shell-session.injectable.ts +++ b/src/main/shell-session/create-shell-session.injectable.ts @@ -12,7 +12,7 @@ export interface OpenShellSessionArgs { websocket: WebSocket; cluster: Cluster; tabId: string; - nodeName?: string; + nodeName: string | null; } export type OpenShellSession = (args: OpenShellSessionArgs) => Promise; diff --git a/src/renderer/api/default-websocket-params.injectable.ts b/src/renderer/api/default-websocket-params.injectable.ts new file mode 100644 index 0000000000..e22d7176dc --- /dev/null +++ b/src/renderer/api/default-websocket-params.injectable.ts @@ -0,0 +1,22 @@ +/** + * 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 { TerminalMessage } from "../../common/terminal/channels"; +import { TerminalChannels } from "../../common/terminal/channels"; +import isDevelopmentInjectable from "../../common/vars/is-development.injectable"; + +export type DefaultWebsocketApiParams = ReturnType<(typeof defaultWebsocketApiParamsInjectable)["instantiate"]>; + +const defaultWebsocketApiParamsInjectable = getInjectable({ + id: "default-websocket-api-params", + instantiate: (di) => ({ + logging: di.inject(isDevelopmentInjectable), + reconnectDelay: 10, + flushOnOpen: true, + pingMessage: JSON.stringify({ type: TerminalChannels.PING } as TerminalMessage), + }), +}); + +export default defaultWebsocketApiParamsInjectable; diff --git a/src/renderer/api/websocket-api.ts b/src/renderer/api/websocket-api.ts index b172655dab..51a1906a62 100644 --- a/src/renderer/api/websocket-api.ts +++ b/src/renderer/api/websocket-api.ts @@ -6,9 +6,8 @@ import { observable, makeObservable } from "mobx"; import EventEmitter from "events"; import type TypedEventEmitter from "typed-emitter"; -import type { Arguments } from "typed-emitter"; import type { Defaulted } from "../utils"; -import type { DefaultWebsocketApiParams } from "./default-websocket-api-params.injectable"; +import type { DefaultWebsocketApiParams } from "./default-websocket-params.injectable"; interface WebsocketApiParams { /** @@ -72,7 +71,7 @@ export class WebSocketApi extends (EventEmitter protected pendingCommands: string[] = []; protected reconnectTimer?: number; protected pingTimer?: number; - protected params: Defaulted; + protected readonly params: Defaulted; @observable readyState = WebSocketApiState.PENDING; @@ -157,14 +156,14 @@ export class WebSocketApi extends (EventEmitter } protected _onOpen(evt: Event) { - this.emit("open", ...[] as Arguments); + (this as TypedEventEmitter).emit("open"); if (this.params.flushOnOpen) this.flush(); this.readyState = WebSocketApiState.OPEN; this.writeLog("%cOPEN", "color:green;font-weight:bold;", evt); } protected _onMessage({ data }: MessageEvent): void { - this.emit("data", ...[data] as Arguments); + (this as TypedEventEmitter).emit("data", data); this.writeLog("%cMESSAGE", "color:black;font-weight:bold;", data); } @@ -188,7 +187,7 @@ export class WebSocketApi extends (EventEmitter } } else { this.readyState = WebSocketApiState.CLOSED; - this.emit("close", ...[] as Arguments); + (this as TypedEventEmitter).emit("close"); } this.writeLog("%cCLOSE", `color:${error ? "red" : "black"};font-weight:bold;`, evt); }