From 743597e2f545b15415316118ac8cdd55c09b8dfc Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 12 Apr 2021 02:51:25 -0400 Subject: [PATCH] Cleanup shell sessions (#2469) Signed-off-by: Sebastian Malton --- src/main/lens-proxy.ts | 8 +- src/main/node-shell-session.ts | 159 ------------ src/main/shell-session.ts | 231 ------------------ src/main/shell-session/index.ts | 2 + src/main/shell-session/local-shell-session.ts | 40 +++ src/main/shell-session/node-shell-session.ts | 108 ++++++++ src/main/shell-session/shell-session.ts | 179 ++++++++++++++ 7 files changed, 335 insertions(+), 392 deletions(-) delete mode 100644 src/main/node-shell-session.ts delete mode 100644 src/main/shell-session.ts create mode 100644 src/main/shell-session/index.ts create mode 100644 src/main/shell-session/local-shell-session.ts create mode 100644 src/main/shell-session/node-shell-session.ts create mode 100644 src/main/shell-session/shell-session.ts diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy.ts index 0bc3528a33..98bdd97f54 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy.ts @@ -5,11 +5,11 @@ import httpProxy from "http-proxy"; import url from "url"; import * as WebSocket from "ws"; import { apiPrefix, apiKubePrefix } from "../common/vars"; -import { openShell } from "./node-shell-session"; import { Router } from "./router"; import { ClusterManager } from "./cluster-manager"; import { ContextHandler } from "./context-handler"; import logger from "./logger"; +import { NodeShellSession, LocalShellSession } from "./shell-session"; export class LensProxy { protected origin: string; @@ -173,8 +173,12 @@ export class LensProxy { return ws.on("connection", ((socket: WebSocket, req: http.IncomingMessage) => { const cluster = this.clusterManager.getClusterForRequest(req); const nodeParam = url.parse(req.url, true).query["node"]?.toString(); + const shell = nodeParam + ? new NodeShellSession(socket, cluster, nodeParam) + : new LocalShellSession(socket, cluster); - openShell(socket, cluster, nodeParam); + shell.open() + .catch(error => logger.error(`[SHELL-SESSION]: failed to open: ${error}`, { error })); })); } diff --git a/src/main/node-shell-session.ts b/src/main/node-shell-session.ts deleted file mode 100644 index a0dc0dc792..0000000000 --- a/src/main/node-shell-session.ts +++ /dev/null @@ -1,159 +0,0 @@ -import * as WebSocket from "ws"; -import * as pty from "node-pty"; -import { ShellSession } from "./shell-session"; -import { v4 as uuid } from "uuid"; -import * as k8s from "@kubernetes/client-node"; -import { KubeConfig } from "@kubernetes/client-node"; -import { Cluster } from "./cluster"; -import logger from "./logger"; -import { appEventBus } from "../common/event-bus"; - -export class NodeShellSession extends ShellSession { - protected nodeName: string; - protected podId: string; - protected kc: KubeConfig; - - constructor(socket: WebSocket, cluster: Cluster, nodeName: string) { - super(socket, cluster); - this.nodeName = nodeName; - this.podId = `node-shell-${uuid()}`; - } - - public async open() { - // these are needed by the ShellSession getCachedShellEnv() method - this.kubeconfigPath = await this.cluster.getProxyKubeconfigPath(); - this.kubectlBinDir = await this.kubectl.binDir(); - - this.kc = await this.cluster.getProxyKubeconfig(); - const shell = await this.kubectl.getPath(); - let args = []; - - if (this.createNodeShellPod(this.podId, this.nodeName)) { - await this.waitForRunningPod(this.podId).catch(() => { - this.exitAndClean(1001); - }); - } - args = ["exec", "-i", "-t", "-n", "kube-system", this.podId, "--", "sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))"]; - - const shellEnv = await this.getCachedShellEnv(); - - this.shellProcess = pty.spawn(shell, args, { - cols: 80, - cwd: this.cwd() || shellEnv["HOME"], - env: shellEnv, - name: "xterm-256color", - rows: 30, - }); - this.running = true; - this.pipeStdout(); - this.pipeStdin(); - this.closeWebsocketOnProcessExit(); - this.exitProcessOnWebsocketClose(); - - appEventBus.emit({name: "node-shell", action: "open"}); - } - - protected exitAndClean(code = 1000) { - if (this.podId) { - this.deleteNodeShellPod(); - } - - if (code != 1000) { - this.sendResponse("Error occurred. "); - } - } - - protected async createNodeShellPod(podId: string, nodeName: string) { - const kc = this.getKubeConfig(); - const k8sApi = kc.makeApiClient(k8s.CoreV1Api); - const pod = { - metadata: { - name: podId, - namespace: "kube-system" - }, - spec: { - nodeName, - restartPolicy: "Never", - terminationGracePeriodSeconds: 0, - hostPID: true, - hostIPC: true, - hostNetwork: true, - tolerations: [{ - operator: "Exists" - }], - containers: [{ - name: "shell", - image: "docker.io/alpine:3.12", - securityContext: { - privileged: true, - }, - command: ["nsenter"], - args: ["-t", "1", "-m", "-u", "-i", "-n", "sleep", "14000"] - }], - } - } as k8s.V1Pod; - - await k8sApi.createNamespacedPod("kube-system", pod).catch((error) => { - logger.error(error); - - return false; - }); - - return true; - } - - protected getKubeConfig() { - if (this.kc) { - return this.kc; - } - this.kc = new k8s.KubeConfig(); - this.kc.loadFromFile(this.kubeconfigPath); - - return this.kc; - } - - protected waitForRunningPod(podId: string) { - return new Promise(async (resolve, reject) => { - const kc = this.getKubeConfig(); - const watch = new k8s.Watch(kc); - const req = await watch.watch(`/api/v1/namespaces/kube-system/pods`, {}, - // callback is called for each received object. - (type, obj) => { - if (obj.metadata.name == podId && obj.status.phase === "Running") { - resolve(true); - } - }, - // done callback is called if the watch terminates normally - (err) => { - logger.error(err); - reject(false); - } - ); - - setTimeout(() => { - req.abort(); - reject(false); - }, 120 * 1000); - }); - } - - protected deleteNodeShellPod() { - const kc = this.getKubeConfig(); - const k8sApi = kc.makeApiClient(k8s.CoreV1Api); - - k8sApi.deleteNamespacedPod(this.podId, "kube-system"); - } -} - -export async function openShell(socket: WebSocket, cluster: Cluster, nodeName?: string): Promise { - let shell: ShellSession; - - if (nodeName) { - shell = new NodeShellSession(socket, cluster, nodeName); - } else { - shell = new ShellSession(socket, cluster); - } - shell.open(); - - return shell; -} diff --git a/src/main/shell-session.ts b/src/main/shell-session.ts deleted file mode 100644 index 1582ede43e..0000000000 --- a/src/main/shell-session.ts +++ /dev/null @@ -1,231 +0,0 @@ -import * as pty from "node-pty"; -import * as WebSocket from "ws"; -import { EventEmitter } from "events"; -import path from "path"; -import shellEnv from "shell-env"; -import { app } from "electron"; -import { Kubectl } from "./kubectl"; -import { Cluster } from "./cluster"; -import { helmCli } from "./helm/helm-cli"; -import { isWindows } from "../common/vars"; -import { appEventBus } from "../common/event-bus"; -import { userStore } from "../common/user-store"; -import { clearKubeconfigEnvVars } from "./utils/clear-kube-env-vars"; - -export class ShellSession extends EventEmitter { - static shellEnvs: Map = new Map(); - - protected websocket: WebSocket; - protected shellProcess: pty.IPty; - protected kubeconfigPath: string; - protected nodeShellPod: string; - protected kubectl: Kubectl; - protected kubectlBinDir: string; - protected kubectlPathDir: string; - protected helmBinDir: string; - protected running = false; - protected cluster: Cluster; - - constructor(socket: WebSocket, cluster: Cluster) { - super(); - this.websocket = socket; - this.kubectl = new Kubectl(cluster.version); - this.cluster = cluster; - } - - public async open() { - this.kubeconfigPath = await this.cluster.getProxyKubeconfigPath(); - this.kubectlBinDir = await this.kubectl.binDir(); - const pathFromPreferences = userStore.preferences.kubectlBinariesPath || this.kubectl.getBundledPath(); - - this.kubectlPathDir = userStore.preferences.downloadKubectlBinaries ? this.kubectlBinDir : path.dirname(pathFromPreferences); - this.helmBinDir = helmCli.getBinaryDir(); - const env = await this.getCachedShellEnv(); - const shell = env.PTYSHELL; - const args = await this.getShellArgs(shell); - - this.shellProcess = pty.spawn(shell, args, { - cols: 80, - cwd: this.cwd() || env.HOME, - env, - name: "xterm-256color", - rows: 30, - }); - this.running = true; - - this.pipeStdout(); - this.pipeStdin(); - this.closeWebsocketOnProcessExit(); - this.exitProcessOnWebsocketClose(); - - appEventBus.emit({name: "shell", action: "open"}); - } - - protected cwd(): string { - const { preferences } = this.cluster; - - if(!preferences || !preferences.terminalCWD || preferences.terminalCWD === "") { - return null; - } - - return preferences.terminalCWD; - } - - protected async getShellArgs(shell: string): Promise> { - switch(path.basename(shell)) { - case "powershell.exe": - return ["-NoExit", "-command", `& {Set-Location $Env:USERPROFILE; $Env:PATH="${this.helmBinDir};${this.kubectlPathDir};$Env:PATH"}`]; - case "bash": - return ["--init-file", path.join(this.kubectlBinDir, ".bash_set_path")]; - case "fish": - return ["--login", "--init-command", `export PATH="${this.helmBinDir}:${this.kubectlPathDir}:$PATH"; export KUBECONFIG="${this.kubeconfigPath}"`]; - case "zsh": - return ["--login"]; - default: - return []; - } - } - - protected async getCachedShellEnv() { - const { id: clusterId } = this.cluster; - - let env = ShellSession.shellEnvs.get(clusterId); - - if (!env) { - env = await this.getShellEnv(); - ShellSession.shellEnvs.set(clusterId, env); - } else { - // refresh env in the background - this.getShellEnv().then((shellEnv: any) => { - ShellSession.shellEnvs.set(clusterId, shellEnv); - }); - } - - return env; - } - - protected async getShellEnv() { - const env = clearKubeconfigEnvVars(JSON.parse(JSON.stringify(await shellEnv()))); - const pathStr = [this.kubectlBinDir, this.helmBinDir, process.env.PATH].join(path.delimiter); - const shell = userStore.preferences.shell || process.env.SHELL || process.env.PTYSHELL; - const { preferences } = this.cluster; - - if(isWindows) { - env["SystemRoot"] = process.env.SystemRoot; - env["PTYSHELL"] = shell || "powershell.exe"; - env["PATH"] = pathStr; - env["LENS_SESSION"] = "true"; - const lensWslEnv = "KUBECONFIG/up:LENS_SESSION/u"; - - if (process.env.WSLENV != undefined) { - env["WSLENV"] = `${process.env["WSLENV"]}:${lensWslEnv}`; - } else { - env["WSLENV"] = lensWslEnv; - } - } else if(shell !== undefined) { - env["PTYSHELL"] = shell; - env["PATH"] = pathStr; - } else { - env["PTYSHELL"] = ""; // blank runs the system default shell - } - - if(path.basename(env["PTYSHELL"]) === "zsh") { - env["OLD_ZDOTDIR"] = env.ZDOTDIR || env.HOME; - env["ZDOTDIR"] = this.kubectlBinDir; - env["DISABLE_AUTO_UPDATE"] = "true"; - } - - env["PTYPID"] = process.pid.toString(); - env["KUBECONFIG"] = this.kubeconfigPath; - env["TERM_PROGRAM"] = app.getName(); - env["TERM_PROGRAM_VERSION"] = app.getVersion(); - - if (preferences.httpsProxy) { - env["HTTPS_PROXY"] = preferences.httpsProxy; - } - const no_proxy = ["localhost", "127.0.0.1", env["NO_PROXY"]]; - - env["NO_PROXY"] = no_proxy.filter(address => !!address).join(); - - if (env.DEBUG) { // do not pass debug option to bash - delete env["DEBUG"]; - } - - return(env); - } - - protected pipeStdout() { - // send shell output to websocket - this.shellProcess.onData(((data: string) => { - this.sendResponse(data); - })); - } - - protected pipeStdin() { - // write websocket messages to shellProcess - this.websocket.on("message", (data: string) => { - if (!this.running) { return; } - - const message = Buffer.from(data.slice(1, data.length), "base64").toString(); - - switch (data[0]) { - case "0": - this.shellProcess.write(message); - break; - case "4": - const resizeMsgObj = JSON.parse(message); - - this.shellProcess.resize(resizeMsgObj["Width"], resizeMsgObj["Height"]); - break; - case "9": - this.emit("newToken", message); - break; - } - }); - } - - protected exit(code = 1000) { - if (this.websocket.readyState == this.websocket.OPEN) this.websocket.close(code); - this.emit("exit"); - } - - protected closeWebsocketOnProcessExit() { - this.shellProcess.onExit(({ exitCode }) => { - this.running = false; - let timeout = 0; - - if (exitCode > 0) { - this.sendResponse("Terminal will auto-close in 15 seconds ..."); - timeout = 15*1000; - } - setTimeout(() => { - this.exit(); - }, timeout); - }); - } - - protected exitProcessOnWebsocketClose() { - this.websocket.on("close", () => { - this.killShellProcess(); - }); - } - - protected killShellProcess(){ - if(this.running) { - // On Windows we need to kill the shell process by pid, since Lens won't respond after a while if using `this.shellProcess.kill()` - if (isWindows) { - try { - process.kill(this.shellProcess.pid); - } catch(e) { - return; - } - } else { - this.shellProcess.kill(); - } - } - } - - protected sendResponse(msg: string) { - this.websocket.send(`1${Buffer.from(msg).toString("base64")}`); - } -} diff --git a/src/main/shell-session/index.ts b/src/main/shell-session/index.ts new file mode 100644 index 0000000000..96743adcc6 --- /dev/null +++ b/src/main/shell-session/index.ts @@ -0,0 +1,2 @@ +export * from "./node-shell-session"; +export * from "./local-shell-session"; diff --git a/src/main/shell-session/local-shell-session.ts b/src/main/shell-session/local-shell-session.ts new file mode 100644 index 0000000000..c131ea651c --- /dev/null +++ b/src/main/shell-session/local-shell-session.ts @@ -0,0 +1,40 @@ +import path from "path"; +import { helmCli } from "../helm/helm-cli"; +import { userStore } from "../../common/user-store"; +import { ShellSession } from "./shell-session"; + +export class LocalShellSession extends ShellSession { + ShellType = "shell"; + + protected getPathEntries(): string[] { + return [helmCli.getBinaryDir()]; + } + + public async open() { + + const env = await this.getCachedShellEnv(); + const shell = env.PTYSHELL; + const args = await this.getShellArgs(shell); + + super.open(env.PTYSHELL, args, env); + } + + protected async getShellArgs(shell: string): Promise { + const helmpath = helmCli.getBinaryDir(); + const pathFromPreferences = userStore.preferences.kubectlBinariesPath || this.kubectl.getBundledPath(); + const kubectlPathDir = userStore.preferences.downloadKubectlBinaries ? await this.kubectlBinDirP : path.dirname(pathFromPreferences); + + switch(path.basename(shell)) { + case "powershell.exe": + return ["-NoExit", "-command", `& {Set-Location $Env:USERPROFILE; $Env:PATH="${helmpath};${kubectlPathDir};$Env:PATH"}`]; + case "bash": + return ["--init-file", path.join(await this.kubectlBinDirP, ".bash_set_path")]; + case "fish": + return ["--login", "--init-command", `export PATH="${helmpath}:${kubectlPathDir}:$PATH"; export KUBECONFIG="${this.kubeconfigPathP}"`]; + case "zsh": + return ["--login"]; + default: + return []; + } + } +} diff --git a/src/main/shell-session/node-shell-session.ts b/src/main/shell-session/node-shell-session.ts new file mode 100644 index 0000000000..16d467138d --- /dev/null +++ b/src/main/shell-session/node-shell-session.ts @@ -0,0 +1,108 @@ +import * as WebSocket from "ws"; +import { v4 as uuid } from "uuid"; +import * as k8s from "@kubernetes/client-node"; +import { KubeConfig } from "@kubernetes/client-node"; +import { Cluster } from "../cluster"; +import { ShellOpenError, ShellSession } from "./shell-session"; + +export class NodeShellSession extends ShellSession { + ShellType = "node-shell"; + + protected podId = `node-shell-${uuid()}`; + protected kc: KubeConfig; + + constructor(socket: WebSocket, cluster: Cluster, protected nodeName: string) { + super(socket, cluster); + } + + public async open() { + this.kc = await this.cluster.getProxyKubeconfig(); + const shell = await this.kubectl.getPath(); + + try { + await this.createNodeShellPod(); + await this.waitForRunningPod(); + } catch (error) { + this.deleteNodeShellPod(); + this.sendResponse("Error occurred. "); + + throw new ShellOpenError("failed to create node pod", error); + } + + const args = ["exec", "-i", "-t", "-n", "kube-system", this.podId, "--", "sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))"]; + const env = await this.getCachedShellEnv(); + + super.open(shell, args, env); + } + + protected createNodeShellPod() { + return this + .kc + .makeApiClient(k8s.CoreV1Api) + .createNamespacedPod("kube-system", { + metadata: { + name: this.podId, + namespace: "kube-system" + }, + spec: { + nodeName: this.nodeName, + restartPolicy: "Never", + terminationGracePeriodSeconds: 0, + hostPID: true, + hostIPC: true, + hostNetwork: true, + tolerations: [{ + operator: "Exists" + }], + containers: [{ + name: "shell", + image: "docker.io/alpine:3.12", + securityContext: { + privileged: true, + }, + command: ["nsenter"], + args: ["-t", "1", "-m", "-u", "-i", "-n", "sleep", "14000"] + }], + } + }); + } + + protected waitForRunningPod(): Promise { + return new Promise((resolve, reject) => { + const watch = new k8s.Watch(this.kc); + + watch + .watch(`/api/v1/namespaces/kube-system/pods`, + {}, + // callback is called for each received object. + (type, obj) => { + if (obj.metadata.name == this.podId && obj.status.phase === "Running") { + resolve(); + } + }, + // done callback is called if the watch terminates normally + (err) => { + console.log(err); + reject(err); + } + ) + .then(req => { + setTimeout(() => { + console.log("aborting"); + req.abort(); + }, 2 * 60 * 1000); + }) + .catch(err => { + console.log("watch failed"); + reject(err); + }); + }); + } + + protected deleteNodeShellPod() { + this + .kc + .makeApiClient(k8s.CoreV1Api) + .deleteNamespacedPod(this.podId, "kube-system"); + } +} diff --git a/src/main/shell-session/shell-session.ts b/src/main/shell-session/shell-session.ts new file mode 100644 index 0000000000..eebaed0605 --- /dev/null +++ b/src/main/shell-session/shell-session.ts @@ -0,0 +1,179 @@ +import { Cluster } from "../cluster"; +import { Kubectl } from "../kubectl"; +import * as WebSocket from "ws"; +import shellEnv from "shell-env"; +import { app } from "electron"; +import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars"; +import path from "path"; +import { isWindows } from "../../common/vars"; +import { userStore } from "../../common/user-store"; +import * as pty from "node-pty"; +import { appEventBus } from "../../common/event-bus"; + +export class ShellOpenError extends Error { + constructor(message: string, public cause: Error) { + super(`${message}: ${cause}`); + this.name = this.constructor.name; + Error.captureStackTrace(this); + } +} + +export abstract class ShellSession { + abstract ShellType: string; + + static shellEnvs: Map> = new Map(); + + protected kubectl: Kubectl; + protected running = false; + protected shellProcess: pty.IPty; + protected kubectlBinDirP: Promise; + protected kubeconfigPathP: Promise; + + protected get cwd(): string | undefined { + return this.cluster.preferences?.terminalCWD; + } + + constructor(protected websocket: WebSocket, protected cluster: Cluster) { + this.kubectl = new Kubectl(cluster.version); + this.kubeconfigPathP = this.cluster.getProxyKubeconfigPath(); + this.kubectlBinDirP = this.kubectl.binDir(); + } + + open(shell: string, args: string[], env: Record): void { + this.shellProcess = pty.spawn(shell, args, { + cols: 80, + cwd: this.cwd || env.HOME, + env, + name: "xterm-256color", + rows: 30, + }); + this.running = true; + + this.shellProcess.onData(data => this.sendResponse(data)); + this.shellProcess.onExit(({ exitCode }) => { + this.running = false; + + if (exitCode > 0) { + this.sendResponse("Terminal will auto-close in 15 seconds ..."); + setTimeout(() => this.exit(), 15 * 1000); + } else { + this.exit(); + } + }); + + this.websocket + .on("message", (data: string) => { + if (!this.running) { + return; + } + + const message = Buffer.from(data.slice(1, data.length), "base64").toString(); + + switch (data[0]) { + case "0": + this.shellProcess.write(message); + break; + case "4": + const { Width, Height } = JSON.parse(message); + + this.shellProcess.resize(Width, Height); + break; + } + }) + .on("close", () => { + if (this.running) { + try { + process.kill(this.shellProcess.pid); + } catch (e) { + } + } + + this.running = false; + }); + + appEventBus.emit({ name: this.ShellType, action: "open" }); + } + + protected getPathEntries(): string[] { + return []; + } + + protected async getCachedShellEnv() { + const { id: clusterId } = this.cluster; + + let env = ShellSession.shellEnvs.get(clusterId); + + if (!env) { + env = await this.getShellEnv(); + ShellSession.shellEnvs.set(clusterId, env); + } else { + // refresh env in the background + this.getShellEnv().then((shellEnv: any) => { + ShellSession.shellEnvs.set(clusterId, shellEnv); + }); + } + + return env; + } + + protected async getShellEnv() { + const env = clearKubeconfigEnvVars(JSON.parse(JSON.stringify(await shellEnv()))); + const pathStr = [...this.getPathEntries(), await this.kubectlBinDirP, process.env.PATH].join(path.delimiter); + const shell = userStore.preferences.shell || process.env.SHELL || process.env.PTYSHELL; + + delete env.DEBUG; // don't pass DEBUG into shells + + if (isWindows) { + env.SystemRoot = process.env.SystemRoot; + env.PTYSHELL = shell || "powershell.exe"; + env.PATH = pathStr; + env.LENS_SESSION = "true"; + env.WSLENV = [ + process.env.WSLENV, + "KUBECONFIG/up:LENS_SESSION/u" + ] + .filter(Boolean) + .join(":"); + } else if (shell !== undefined) { + env.PTYSHELL = shell; + env.PATH = pathStr; + } else { + env.PTYSHELL = ""; // blank runs the system default shell + } + + if (path.basename(env.PTYSHELL) === "zsh") { + env.OLD_ZDOTDIR = env.ZDOTDIR || env.HOME; + env.ZDOTDIR = this.kubectlBinDirP; + env.DISABLE_AUTO_UPDATE = "true"; + } + + env.PTYPID = process.pid.toString(); + env.KUBECONFIG = await this.kubeconfigPathP; + env.TERM_PROGRAM = app.getName(); + env.TERM_PROGRAM_VERSION = app.getVersion(); + + if (this.cluster.preferences.httpsProxy) { + env.HTTPS_PROXY = this.cluster.preferences.httpsProxy; + } + + env.NO_PROXY = [ + "localhost", + "127.0.0.1", + env.NO_PROXY + ] + .filter(Boolean) + .join(); + + return env; + } + + protected exit(code = 1000) { + if (this.websocket.readyState == this.websocket.OPEN) { + this.websocket.close(code); + } + } + + protected sendResponse(msg: string) { + this.websocket.send(`1${Buffer.from(msg).toString("base64")}`); + } +}