1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Cleanup shell sessions

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2022-05-16 16:44:17 -04:00
parent fb4dca8e58
commit c3f956675a
30 changed files with 462 additions and 424 deletions

View File

@ -4,12 +4,10 @@
*/ */
import "../common/ipc/cluster"; import "../common/ipc/cluster";
import type http from "http";
import { action, makeObservable, observable, observe, reaction, toJS } from "mobx"; import { action, makeObservable, observable, observe, reaction, toJS } from "mobx";
import type { Cluster } from "../common/cluster/cluster"; import type { Cluster } from "../common/cluster/cluster";
import logger from "./logger"; import logger from "./logger";
import { apiKubePrefix } from "../common/vars"; import { isErrnoException } from "../common/utils";
import { getClusterIdFromHost, isErrnoException } from "../common/utils";
import type { KubernetesClusterPrometheusMetrics } from "../common/catalog-entities/kubernetes-cluster"; import type { KubernetesClusterPrometheusMetrics } from "../common/catalog-entities/kubernetes-cluster";
import { isKubernetesCluster, KubernetesCluster, LensKubernetesClusterStatus } from "../common/catalog-entities/kubernetes-cluster"; import { isKubernetesCluster, KubernetesCluster, LensKubernetesClusterStatus } from "../common/catalog-entities/kubernetes-cluster";
import { ipcMainOn } from "../common/ipc"; import { ipcMainOn } from "../common/ipc";
@ -260,27 +258,6 @@ export class ClusterManager {
cluster.disconnect(); cluster.disconnect();
}); });
} }
getClusterForRequest = (req: http.IncomingMessage): Cluster | undefined => {
if (!req.headers.host) {
return undefined;
}
// lens-server is connecting to 127.0.0.1:<port>/<uid>
if (req.url && req.headers.host.startsWith("127.0.0.1")) {
const clusterId = req.url.split("/")[1];
const cluster = this.dependencies.store.getById(clusterId);
if (cluster) {
// we need to swap path prefix so that request is proxied to kube api
req.url = req.url.replace(`/${clusterId}`, apiKubePrefix);
}
return cluster;
}
return this.dependencies.store.getById(getClusterIdFromHost(req.headers.host));
};
} }
export function catalogEntityFromCluster(cluster: Cluster) { export function catalogEntityFromCluster(cluster: Cluster) {

View File

@ -72,6 +72,8 @@ export class Kubectl {
version = new SemVer(Kubectl.bundledKubectlVersion); version = new SemVer(Kubectl.bundledKubectlVersion);
} }
console.log(`Parsed ${clusterVersion} as ${version}`);
const fromMajorMinor = kubectlMap.get(`${version.major}.${version.minor}`); const fromMajorMinor = kubectlMap.get(`${version.major}.${version.minor}`);
/** /**

View File

@ -0,0 +1,42 @@
/**
* 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 { IncomingMessage } from "http";
import clusterStoreInjectable from "../../common/cluster-store/cluster-store.injectable";
import type { Cluster } from "../../common/cluster/cluster";
import { getClusterIdFromHost } from "../../common/utils";
import { apiKubePrefix } from "../../common/vars";
export type GetClusterForRequest = (req: IncomingMessage) => Cluster | undefined;
const getClusterForRequestInjectable = getInjectable({
id: "get-cluster-for-request",
instantiate: (di): GetClusterForRequest => {
const store = di.inject(clusterStoreInjectable);
return (req) => {
if (!req.headers.host) {
return undefined;
}
// lens-server is connecting to 127.0.0.1:<port>/<uid>
if (req.url && req.headers.host.startsWith("127.0.0.1")) {
const clusterId = req.url.split("/")[1];
const cluster = store.getById(clusterId);
if (cluster) {
// we need to swap path prefix so that request is proxied to kube api
req.url = req.url.replace(`/${clusterId}`, apiKubePrefix);
}
return cluster;
}
return store.getById(getClusterIdFromHost(req.headers.host));
};
},
});
export default getClusterForRequestInjectable;

View File

@ -8,27 +8,27 @@ const lensProxyPortInjectable = getInjectable({
id: "lens-proxy-port", id: "lens-proxy-port",
instantiate: () => { instantiate: () => {
let _portNumber: number; let portNumber: number;
return { return {
get: () => { get: () => {
if (!_portNumber) { if (!portNumber) {
throw new Error( throw new Error(
"Tried to access port number of LensProxy while it has not been set yet.", "Tried to access port number of LensProxy while it has not been set yet.",
); );
} }
return _portNumber; return portNumber;
}, },
set: (portNumber: number) => { set: (port: number) => {
if (_portNumber) { if (port) {
throw new Error( throw new Error(
"Tried to set port number for LensProxy when it has already been set.", "Tried to set port number for LensProxy when it has already been set.",
); );
} }
_portNumber = portNumber; portNumber = port;
}, },
}; };
}, },

View File

@ -4,32 +4,24 @@
*/ */
import { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import { LensProxy } from "./lens-proxy"; import { LensProxy } from "./lens-proxy";
import { kubeApiUpgradeRequest } from "./proxy-functions";
import routerInjectable from "../router/router.injectable"; import routerInjectable from "../router/router.injectable";
import httpProxy from "http-proxy"; import httpProxy from "http-proxy";
import clusterManagerInjectable from "../cluster-manager.injectable"; import shellApiRequestInjectable from "./proxy-functions/shell/api-request.injectable";
import shellApiRequestInjectable from "./proxy-functions/shell-api-request/shell-api-request.injectable";
import lensProxyPortInjectable from "./lens-proxy-port.injectable"; import lensProxyPortInjectable from "./lens-proxy-port.injectable";
import getClusterForRequestInjectable from "./get-cluster-for-request.injectable";
import kubeApiUpgradeRequestInjectable from "./proxy-functions/kube/api-upgrade-request.injectable";
const lensProxyInjectable = getInjectable({ const lensProxyInjectable = getInjectable({
id: "lens-proxy", id: "lens-proxy",
instantiate: (di) => { instantiate: (di) => new LensProxy({
const clusterManager = di.inject(clusterManagerInjectable); router: di.inject(routerInjectable),
const router = di.inject(routerInjectable); proxy: httpProxy.createProxy(),
const shellApiRequest = di.inject(shellApiRequestInjectable); kubeApiUpgradeRequest: di.inject(kubeApiUpgradeRequestInjectable),
const proxy = httpProxy.createProxy(); shellApiRequest: di.inject(shellApiRequestInjectable),
const lensProxyPort = di.inject(lensProxyPortInjectable); getClusterForRequest: di.inject(getClusterForRequestInjectable),
lensProxyPort: di.inject(lensProxyPortInjectable),
return new LensProxy({ }),
router,
proxy,
kubeApiUpgradeRequest,
shellApiRequest,
getClusterForRequest: clusterManager.getClusterForRequest,
lensProxyPort,
});
},
}); });
export default lensProxyInjectable; export default lensProxyInjectable;

View File

@ -11,21 +11,20 @@ import { apiPrefix, apiKubePrefix, contentSecurityPolicy } from "../../common/va
import type { Router } from "../router/router"; import type { Router } from "../router/router";
import type { ClusterContextHandler } from "../context-handler/context-handler"; import type { ClusterContextHandler } from "../context-handler/context-handler";
import logger from "../logger"; import logger from "../logger";
import type { Cluster } from "../../common/cluster/cluster";
import type { ProxyApiRequestArgs } from "./proxy-functions"; import type { ProxyApiRequestArgs } from "./proxy-functions";
import { appEventBus } from "../../common/app-event-bus/event-bus"; import { appEventBus } from "../../common/app-event-bus/event-bus";
import { getBoolean } from "../utils/parse-query"; import { getBoolean } from "../utils/parse-query";
import assert from "assert"; import assert from "assert";
import type { SetRequired } from "type-fest"; import type { SetRequired } from "type-fest";
import type { GetClusterForRequest } from "./get-cluster-for-request.injectable";
type GetClusterForRequest = (req: http.IncomingMessage) => Cluster | undefined;
export type ServerIncomingMessage = SetRequired<http.IncomingMessage, "url" | "method">; export type ServerIncomingMessage = SetRequired<http.IncomingMessage, "url" | "method">;
export type ProxyApiRequest = (args: ProxyApiRequestArgs) => void | Promise<void>;
interface Dependencies { interface Dependencies {
getClusterForRequest: GetClusterForRequest; getClusterForRequest: GetClusterForRequest;
shellApiRequest: (args: ProxyApiRequestArgs) => void | Promise<void>; shellApiRequest: ProxyApiRequest;
kubeApiUpgradeRequest: (args: ProxyApiRequestArgs) => void | Promise<void>; kubeApiUpgradeRequest: ProxyApiRequest;
router: Router; router: Router;
proxy: httpProxy; proxy: httpProxy;
lensProxyPort: { set: (portNumber: number) => void }; lensProxyPort: { set: (portNumber: number) => void };

View File

@ -2,5 +2,4 @@
* Copyright (c) OpenLens Authors. All rights reserved. * Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
export * from "./kube-api-upgrade-request";
export * from "./types"; export * from "./types";

View File

@ -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();
});
}

View File

@ -0,0 +1,79 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { chunk, pick } from "lodash";
import type { ConnectionOptions } from "tls";
import { connect } from "tls";
import url from "url";
import { apiKubePrefix } from "../../../../common/vars";
import { getInjectable } from "@ogre-tools/injectable";
import loggerInjectable from "../../../../common/logger.injectable";
import type { ProxyApiRequest } from "../../lens-proxy";
const skipRawHeaders = new Set(["Host", "Authorization"]);
const kubeApiUpgradeRequestInjectable = getInjectable({
id: "kube-api-upgrade-request",
instantiate: (di): ProxyApiRequest => {
const logger = di.inject(loggerInjectable);
return 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,
};
logger.debug(`[KUBE-API-UPGRADE]: connecting for clusterId=${cluster.id} for url=${proxyUrl}`, pick(connectOpts, "port", "host"));
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();
});
};
},
});
export default kubeApiUpgradeRequestInjectable;

View File

@ -1,21 +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 { shellApiRequest } from "./shell-api-request";
import createShellSessionInjectable from "../../../shell-session/create-shell-session.injectable";
import shellRequestAuthenticatorInjectable from "./shell-request-authenticator/shell-request-authenticator.injectable";
import clusterManagerInjectable from "../../../cluster-manager.injectable";
const shellApiRequestInjectable = getInjectable({
id: "shell-api-request",
instantiate: (di) => shellApiRequest({
createShellSession: di.inject(createShellSessionInjectable),
authenticateRequest: di.inject(shellRequestAuthenticatorInjectable).authenticate,
clusterManager: di.inject(clusterManagerInjectable),
}),
});
export default shellApiRequestInjectable;

View File

@ -1,46 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import logger from "../../../logger";
import type WebSocket from "ws";
import { Server as WebSocketServer } from "ws";
import type { ProxyApiRequestArgs } from "../types";
import type { ClusterManager } from "../../../cluster-manager";
import URLParse from "url-parse";
import type { Cluster } from "../../../../common/cluster/cluster";
import type { ClusterId } from "../../../../common/cluster-types";
interface Dependencies {
authenticateRequest: (clusterId: ClusterId, tabId: string, shellToken: string | undefined) => boolean;
createShellSession: (args: {
webSocket: WebSocket;
cluster: Cluster;
tabId: string;
nodeName?: string;
}) => { open: () => Promise<void> };
clusterManager: ClusterManager;
}
export const shellApiRequest = ({ createShellSession, authenticateRequest, clusterManager }: Dependencies) => ({ req, socket, head }: ProxyApiRequestArgs): void => {
const cluster = clusterManager.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");
return void socket.end();
}
const ws = new WebSocketServer({ noServer: true });
ws.handleUpgrade(req, socket, head, (webSocket) => {
const shell = createShellSession({ webSocket, cluster, tabId, nodeName });
shell.open()
.catch(error => logger.error(`[SHELL-SESSION]: failed to open a ${nodeName ? "node" : "local"} shell`, error));
});
};

View File

@ -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;

View File

@ -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<ClusterId, Map<string, Uint8Array>>();
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;
};
}

View File

@ -0,0 +1,43 @@
/**
* 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 openShellSessionInjectable from "../../../shell-session/open-shell-session.injectable";
import loggerInjectable from "../../../../common/logger.injectable";
import type { ProxyApiRequest } from "../../lens-proxy";
import getClusterForRequestInjectable from "../../get-cluster-for-request.injectable";
import { Server } from "ws";
import authenticateRequestInjectable from "./authenticate-request.injectable";
const shellApiRequestInjectable = getInjectable({
id: "shell-api-request",
instantiate: (di): ProxyApiRequest => {
const openShellSession = di.inject(openShellSessionInjectable);
const authenticateRequest = di.inject(authenticateRequestInjectable);
const logger = di.inject(loggerInjectable);
const getClusterForRequest = di.inject(getClusterForRequestInjectable);
return ({ req, socket, head }) => {
const cluster = getClusterForRequest(req);
const { searchParams } = new URL(req.url);
const nodeName = searchParams.get("node") || undefined;
const shellToken = searchParams.get("shellToken");
const tabId = searchParams.get("id");
if (!tabId || !cluster || !shellToken || !authenticateRequest(cluster.id, tabId, shellToken)) {
socket.write("Invalid shell request");
socket.end();
} else {
new Server({ noServer: true })
.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;

View File

@ -0,0 +1,13 @@
/**
* 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 "./request-authenticator.injectable";
const authenticateRequestInjectable = getInjectable({
id: "authenticate-request",
instantiate: (di) => di.inject(shellRequestAuthenticatorInjectable).authenticate,
});
export default authenticateRequestInjectable;

View File

@ -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 { timingSafeEqual } from "crypto";
import type { ClusterId } from "../../../../common/cluster-types";
import { getOrInsertMap } from "../../../../common/utils";
import randomBytesInjectable from "../../../utils/random-bytes.injectable";
export interface ShellRequestAuthenticator {
authenticate(clusterId: ClusterId, tabId: string, token: string | undefined): boolean;
getTokenFor(clusterId: ClusterId, tabId: string): Promise<Uint8Array>;
}
const shellRequestAuthenticatorInjectable = getInjectable({
id: "shell-request-authenticator",
instantiate: (di): ShellRequestAuthenticator => {
const randomBytes = di.inject(randomBytesInjectable);
const tokens = new Map<ClusterId, Map<string, Uint8Array>>();
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 && timingSafeEqual(authToken, buf)) {
// remove the token because it is a single use token
clusterTokens.delete(tabId);
return true;
}
return false;
},
getTokenFor: async (clusterId, tabId) => {
const authToken = Uint8Array.from(await randomBytes(128));
const forCluster = getOrInsertMap(tokens, clusterId);
forCluster.set(tabId, authToken);
return authToken;
},
};
},
});
export default shellRequestAuthenticatorInjectable;

View File

@ -1,29 +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 type { Cluster } from "../../common/cluster/cluster";
import type WebSocket from "ws";
import localShellSessionInjectable from "./local-shell-session/local-shell-session.injectable";
import nodeShellSessionInjectable from "./node-shell-session/node-shell-session.injectable";
interface Args {
webSocket: WebSocket;
cluster: Cluster;
tabId: string;
nodeName?: string;
}
const createShellSessionInjectable = getInjectable({
id: "create-shell-session",
instantiate:
(di) =>
({ nodeName, ...rest }: Args) =>
!nodeName
? di.inject(localShellSessionInjectable, rest)
: di.inject(nodeShellSessionInjectable, { nodeName, ...rest }),
});
export default createShellSessionInjectable;

View File

@ -1,33 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { LocalShellSession } from "./local-shell-session";
import type { Cluster } from "../../../common/cluster/cluster";
import type WebSocket from "ws";
import createKubectlInjectable from "../../kubectl/create-kubectl.injectable";
import terminalShellEnvModifiersInjectable from "../shell-env-modifier/terminal-shell-env-modify.injectable";
interface InstantiationParameter {
webSocket: WebSocket;
cluster: Cluster;
tabId: string;
}
const localShellSessionInjectable = getInjectable({
id: "local-shell-session",
instantiate: (di, { cluster, tabId, webSocket }: InstantiationParameter) => {
const createKubectl = di.inject(createKubectlInjectable);
const localShellEnvModify = di.inject(terminalShellEnvModifiersInjectable);
const kubectl = createKubectl(cluster.version);
return new LocalShellSession(localShellEnvModify, kubectl, webSocket, cluster, tabId);
},
lifecycle: lifecycleEnum.transient,
});
export default localShellSessionInjectable;

View File

@ -3,24 +3,26 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import type WebSocket from "ws";
import path from "path"; import path from "path";
import { UserStore } from "../../../common/user-store"; import { UserStore } from "../../../common/user-store";
import type { Cluster } from "../../../common/cluster/cluster"; import type { TerminalShellEnvModify } from "../shell-env-modifier/terminal-shell-env-modify.injectable";
import type { ClusterId } from "../../../common/cluster-types"; import type { ShellSessionArgs } from "../shell-session";
import { ShellSession } from "../shell-session"; import { ShellSession } from "../shell-session";
import type { Kubectl } from "../../kubectl/kubectl";
import { baseBinariesDir } from "../../../common/vars"; export interface LocalShellSessionDependencies {
terminalShellEnvModify: TerminalShellEnvModify;
readonly baseBundeledBinariesDirectory: string;
}
export class LocalShellSession extends ShellSession { export class LocalShellSession extends ShellSession {
ShellType = "shell"; ShellType = "shell";
constructor(protected shellEnvModify: (clusterId: ClusterId, env: Record<string, string | undefined>) => Record<string, string | undefined>, kubectl: Kubectl, websocket: WebSocket, cluster: Cluster, terminalId: string) { constructor(protected readonly dependencies: LocalShellSessionDependencies, args: ShellSessionArgs) {
super(kubectl, websocket, cluster, terminalId); super(args);
} }
protected getPathEntries(): string[] { protected getPathEntries(): string[] {
return [baseBinariesDir.get()]; return [this.dependencies.baseBundeledBinariesDirectory];
} }
protected get cwd(): string | undefined { protected get cwd(): string | undefined {
@ -31,7 +33,7 @@ export class LocalShellSession extends ShellSession {
let env = await this.getCachedShellEnv(); let env = await this.getCachedShellEnv();
// extensions can modify the env // extensions can modify the env
env = this.shellEnvModify(this.cluster.id, env); env = this.dependencies.terminalShellEnvModify(this.cluster.id, env);
const shell = env.PTYSHELL; const shell = env.PTYSHELL;
@ -50,11 +52,11 @@ export class LocalShellSession extends ShellSession {
switch(path.basename(shell)) { switch(path.basename(shell)) {
case "powershell.exe": case "powershell.exe":
return ["-NoExit", "-command", `& {$Env:PATH="${kubectlPathDir};${baseBinariesDir.get()};$Env:PATH"}`]; return ["-NoExit", "-command", `& {$Env:PATH="${kubectlPathDir};${this.dependencies.baseBundeledBinariesDirectory};$Env:PATH"}`];
case "bash": case "bash":
return ["--init-file", path.join(await this.kubectlBinDirP, ".bash_set_path")]; return ["--init-file", path.join(await this.kubectlBinDirP, ".bash_set_path")];
case "fish": case "fish":
return ["--login", "--init-command", `export PATH="${kubectlPathDir}:${baseBinariesDir.get()}:$PATH"; export KUBECONFIG="${await this.kubeconfigPathP}"`]; return ["--login", "--init-command", `export PATH="${kubectlPathDir}:${this.dependencies.baseBundeledBinariesDirectory}:$PATH"; export KUBECONFIG="${await this.kubeconfigPathP}"`];
case "zsh": case "zsh":
return ["--login"]; return ["--login"];
default: default:

View File

@ -0,0 +1,35 @@
/**
* 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 { LocalShellSessionDependencies } from "./local-shell-session";
import { LocalShellSession } from "./local-shell-session";
import type { Cluster } from "../../../common/cluster/cluster";
import type WebSocket from "ws";
import terminalShellEnvModifyInjectable from "../shell-env-modifier/terminal-shell-env-modify.injectable";
import baseBundeledBinariesDirectoryInjectable from "../../../common/vars/base-bundled-binaries-dir.injectable";
import type { Kubectl } from "../../kubectl/kubectl";
export interface OpenLocalShellSessionArgs {
websocket: WebSocket;
cluster: Cluster;
tabId: string;
kubectl: Kubectl;
}
export type OpenLocalShellSession = (args: OpenLocalShellSessionArgs) => Promise<void>;
const openLocalShellSessionInjectable = getInjectable({
id: "open-local-shell-session",
instantiate: (di): OpenLocalShellSession => {
const deps: LocalShellSessionDependencies = {
terminalShellEnvModify: di.inject(terminalShellEnvModifyInjectable),
baseBundeledBinariesDirectory: di.inject(baseBundeledBinariesDirectoryInjectable),
};
return (args) => new LocalShellSession(deps, args).open();
},
});
export default openLocalShellSessionInjectable;

View File

@ -1,32 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import type { Cluster } from "../../../common/cluster/cluster";
import type WebSocket from "ws";
import createKubectlInjectable from "../../kubectl/create-kubectl.injectable";
import { NodeShellSession } from "./node-shell-session";
interface InstantiationParameter {
webSocket: WebSocket;
cluster: Cluster;
tabId: string;
nodeName: string;
}
const nodeShellSessionInjectable = getInjectable({
id: "node-shell-session",
instantiate: (di, { cluster, tabId, webSocket, nodeName }: InstantiationParameter) => {
const createKubectl = di.inject(createKubectlInjectable);
const kubectl = createKubectl(cluster.version);
return new NodeShellSession(nodeName, kubectl, webSocket, cluster, tabId);
},
lifecycle: lifecycleEnum.transient,
});
export default nodeShellSessionInjectable;

View File

@ -3,28 +3,32 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import type WebSocket from "ws";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import { Watch, CoreV1Api } from "@kubernetes/client-node"; import { Watch, CoreV1Api } from "@kubernetes/client-node";
import type { KubeConfig } from "@kubernetes/client-node"; import type { KubeConfig } from "@kubernetes/client-node";
import type { Cluster } from "../../../common/cluster/cluster"; import type { ShellSessionArgs } from "../shell-session";
import { ShellOpenError, ShellSession } from "../shell-session"; import { ShellOpenError, ShellSession } from "../shell-session";
import { get, once } from "lodash"; import { get, once } from "lodash";
import { Node, NodeApi } from "../../../common/k8s-api/endpoints"; import { Node, NodeApi } from "../../../common/k8s-api/endpoints";
import { KubeJsonApi } from "../../../common/k8s-api/kube-json-api"; import { KubeJsonApi } from "../../../common/k8s-api/kube-json-api";
import logger from "../../logger"; import logger from "../../logger";
import type { Kubectl } from "../../kubectl/kubectl";
import { TerminalChannels } from "../../../common/terminal/channels"; import { TerminalChannels } from "../../../common/terminal/channels";
export interface NodeShellSessionArgs extends ShellSessionArgs {
nodeName: string;
}
export class NodeShellSession extends ShellSession { export class NodeShellSession extends ShellSession {
ShellType = "node-shell"; ShellType = "node-shell";
protected readonly podName = `node-shell-${uuid()}`; protected readonly podName = `node-shell-${uuid()}`;
protected readonly cwd: string | undefined = undefined; protected readonly cwd: string | undefined = undefined;
protected readonly nodeName: string;
constructor(protected nodeName: string, kubectl: Kubectl, socket: WebSocket, cluster: Cluster, terminalId: string) { constructor({ nodeName, ...args }: NodeShellSessionArgs) {
super(kubectl, socket, cluster, terminalId); super(args);
this.nodeName = nodeName;
} }
public async open() { public async open() {

View File

@ -0,0 +1,27 @@
/**
* 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 { Cluster } from "../../../common/cluster/cluster";
import type WebSocket from "ws";
import { NodeShellSession } from "./node-shell-session";
import type { Kubectl } from "../../kubectl/kubectl";
export interface OpenNodeShellSessionArgs {
websocket: WebSocket;
cluster: Cluster;
tabId: string;
nodeName: string;
kubectl: Kubectl;
}
export type OpenNodeShellSession = (args: OpenNodeShellSessionArgs) => Promise<void>;
const openNodeShellSessionInjectable = getInjectable({
id: "node-shell-session",
instantiate: (): OpenNodeShellSession => (args) => new NodeShellSession(args).open(),
});
export default openNodeShellSessionInjectable;

View File

@ -0,0 +1,39 @@
/**
* 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 { Cluster } from "../../common/cluster/cluster";
import type WebSocket from "ws";
import openLocalShellSessionInjectable from "./local-shell-session/open-local-shell-session.injectable";
import openNodeShellSessionInjectable from "./node-shell-session/open-node-shell-session.injectable";
import createKubectlInjectable from "../kubectl/create-kubectl.injectable";
export interface OpenShellSessionArgs {
websocket: WebSocket;
cluster: Cluster;
tabId: string;
nodeName?: string;
}
export type OpenShellSession = (args: OpenShellSessionArgs) => Promise<void>;
const openShellSessionInjectable = getInjectable({
id: "open-shell-session",
instantiate: (di): OpenShellSession => {
const openLocalShellSession = di.inject(openLocalShellSessionInjectable);
const openNodeShellSession = di.inject(openNodeShellSessionInjectable);
const createKubectl = di.inject(createKubectlInjectable);
return ({ nodeName, cluster, ...args }) => {
const kubectl = createKubectl(cluster.version);
return nodeName
? openNodeShellSession({ nodeName, cluster, kubectl, ...args })
: openLocalShellSession({ cluster, kubectl, ...args });
};
},
});
export default openShellSessionInjectable;

View File

@ -9,4 +9,4 @@ export interface ShellEnvContext {
catalogEntity: CatalogEntity; catalogEntity: CatalogEntity;
} }
export type ShellEnvModifier = (ctx: ShellEnvContext, env: Record<string, string | undefined>) => Record<string, string | undefined>; export type ShellEnvModifier = (ctx: ShellEnvContext, env: Partial<Record<string, string>>) => Partial<Record<string, string>>;

View File

@ -0,0 +1,23 @@
/**
* 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 { computed } from "mobx";
import mainExtensionsInjectable from "../../../extensions/main-extensions.injectable";
import { isDefined } from "../../../common/utils";
const terminalShellEnvModifiersInjectable = getInjectable({
id: "terminal-shell-env-modifiers",
instantiate: (di) => {
const extensions = di.inject(mainExtensionsInjectable);
return computed(() => (
extensions.get()
.map((extension) => extension.terminalShellEnvModifier)
.filter(isDefined)
));
},
});
export default terminalShellEnvModifiersInjectable;

View File

@ -1,42 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { IComputedValue } from "mobx";
import { computed } from "mobx";
import type { ClusterId } from "../../../common/cluster-types";
import { isDefined } from "../../../common/utils";
import type { LensMainExtension } from "../../../extensions/lens-main-extension";
import type { CatalogEntityRegistry } from "../../catalog";
interface Dependencies {
extensions: IComputedValue<LensMainExtension[]>;
catalogEntityRegistry: CatalogEntityRegistry;
}
export const terminalShellEnvModify = ({ extensions, catalogEntityRegistry }: Dependencies) =>
(clusterId: ClusterId, env: Record<string, string | undefined>) => {
const terminalShellEnvModifiers = computed(() => (
extensions.get()
.map((extension) => extension.terminalShellEnvModifier)
.filter(isDefined)
))
.get();
if (terminalShellEnvModifiers.length === 0) {
return env;
}
const entity = catalogEntityRegistry.findById(clusterId);
if (entity) {
const ctx = { catalogEntity: entity };
// clone it so the passed value is not also modified
env = JSON.parse(JSON.stringify(env));
env = terminalShellEnvModifiers.reduce((env, modifier) => modifier(ctx, env), env);
}
return env;
};

View File

@ -4,18 +4,39 @@
*/ */
import { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import mainExtensionsInjectable from "../../../extensions/main-extensions.injectable"; import type { ClusterId } from "../../../common/cluster-types";
import { terminalShellEnvModify } from "./terminal-shell-env-modifiers";
import catalogEntityRegistryInjectable from "../../catalog/entity-registry.injectable"; import catalogEntityRegistryInjectable from "../../catalog/entity-registry.injectable";
import terminalShellEnvModifiersInjectable from "./terminal-shell-env-modifiers.injectable";
export type TerminalShellEnvModify = (clusterId: ClusterId, env: Partial<Record<string, string>>) => Partial<Record<string, string>>;
const terminalShellEnvModifyInjectable = getInjectable({ const terminalShellEnvModifyInjectable = getInjectable({
id: "terminal-shell-env-modify", id: "terminal-shell-env-modify",
instantiate: (di) => instantiate: (di): TerminalShellEnvModify => {
terminalShellEnvModify({ const terminalShellEnvModifiers = di.inject(terminalShellEnvModifiersInjectable);
extensions: di.inject(mainExtensionsInjectable), const entityRegistry = di.inject(catalogEntityRegistryInjectable);
catalogEntityRegistry: di.inject(catalogEntityRegistryInjectable),
}), return (clusterId, env) => {
const modifiers = terminalShellEnvModifiers.get();
if (modifiers.length === 0) {
return env;
}
const entity = entityRegistry.findById(clusterId);
if (entity) {
const ctx = { catalogEntity: entity };
// clone it so the passed value is not also modified
env = JSON.parse(JSON.stringify(env));
env = modifiers.reduce((env, modifier) => modifier(ctx, env), env);
}
return env;
};
},
}); });
export default terminalShellEnvModifyInjectable; export default terminalShellEnvModifyInjectable;

View File

@ -104,6 +104,13 @@ export enum WebSocketCloseEvent {
TlsHandshake = 1015, TlsHandshake = 1015,
} }
export interface ShellSessionArgs {
kubectl: Kubectl;
websocket: WebSocket;
cluster: Cluster;
tabId: string;
}
export abstract class ShellSession { export abstract class ShellSession {
abstract readonly ShellType: string; abstract readonly ShellType: string;
@ -130,8 +137,11 @@ export abstract class ShellSession {
protected readonly kubectlBinDirP: Promise<string>; protected readonly kubectlBinDirP: Promise<string>;
protected readonly kubeconfigPathP: Promise<string>; protected readonly kubeconfigPathP: Promise<string>;
protected readonly terminalId: string; protected readonly terminalId: string;
protected readonly kubectl: Kubectl;
protected readonly websocket: WebSocket;
protected readonly cluster: Cluster;
protected abstract get cwd(): string | undefined; protected abstract readonly cwd: string | undefined;
protected ensureShellProcess(shell: string, args: string[], env: Record<string, string | undefined>, cwd: string): { shellProcess: pty.IPty; resume: boolean } { protected ensureShellProcess(shell: string, args: string[], env: Record<string, string | undefined>, cwd: string): { shellProcess: pty.IPty; resume: boolean } {
const resume = ShellSession.processes.has(this.terminalId); const resume = ShellSession.processes.has(this.terminalId);
@ -152,10 +162,13 @@ export abstract class ShellSession {
return { shellProcess, resume }; return { shellProcess, resume };
} }
constructor(protected readonly kubectl: Kubectl, protected readonly websocket: WebSocket, protected readonly cluster: Cluster, terminalId: string) { constructor({ cluster, kubectl, tabId, websocket }: ShellSessionArgs) {
this.cluster = cluster;
this.kubectl = kubectl;
this.websocket = websocket;
this.kubeconfigPathP = this.cluster.getProxyKubeconfigPath(); this.kubeconfigPathP = this.cluster.getProxyKubeconfigPath();
this.kubectlBinDirP = this.kubectl.binDir(); this.kubectlBinDirP = this.kubectl.binDir();
this.terminalId = `${cluster.id}:${terminalId}`; this.terminalId = `${cluster.id}:${tabId}`;
} }
protected send(message: TerminalMessage): void { protected send(message: TerminalMessage): void {

View File

@ -0,0 +1,15 @@
/**
* 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 { randomBytes } from "crypto";
import { promisify } from "util";
const randomBytesInjectable = getInjectable({
id: "random-bytes",
instantiate: () => promisify(randomBytes),
causesSideEffects: true,
});
export default randomBytesInjectable;