diff --git a/package.json b/package.json index 71b7b7f61f99..1fd39ce4344d 100644 --- a/package.json +++ b/package.json @@ -255,6 +255,7 @@ "request": "^2.88.2", "request-promise-native": "^1.0.9", "rfc6902": "^4.0.2", + "selfsigned": "^2.0.0", "semver": "^7.3.2", "shell-env": "^3.0.1", "spdy": "^4.0.2", diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index 9bfc5346ed1f..bf074f732487 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -24,6 +24,8 @@ import { createClusterInjectionToken } from "../cluster/create-cluster-injection import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import kubeAuthProxyCaInjectable from "../../main/kube-auth-proxy/kube-auth-proxy-ca.injectable"; +import createKubeAuthProxyCertFilesInjectable from "../../main/kube-auth-proxy/create-kube-auth-proxy-cert-files.injectable"; console = new Console(stdout, stderr); @@ -87,6 +89,8 @@ describe("cluster-store", () => { mainDi = dis.mainDi; mainDi.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + mainDi.override(createKubeAuthProxyCertFilesInjectable, () => ({} as any)); + mainDi.override(kubeAuthProxyCaInjectable, () => ({} as any)); await dis.runSetups(); diff --git a/src/common/__tests__/hotbar-store.test.ts b/src/common/__tests__/hotbar-store.test.ts index b9fc9d1e9905..e93f53abe549 100644 --- a/src/common/__tests__/hotbar-store.test.ts +++ b/src/common/__tests__/hotbar-store.test.ts @@ -10,6 +10,7 @@ import type { CatalogEntity, CatalogEntityData, CatalogEntityKindData } from ".. import { HotbarStore } from "../hotbar-store"; import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import writeFileInjectable from "../fs/write-file.injectable"; jest.mock("../../main/catalog/catalog-entity-registry", () => ({ catalogEntityRegistry: { @@ -115,6 +116,7 @@ describe("HotbarStore", () => { beforeEach(async () => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); + di.override(writeFileInjectable, () => () => undefined); di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); await di.runSetups(); diff --git a/src/common/__tests__/user-store.test.ts b/src/common/__tests__/user-store.test.ts index da05121bdf79..465acc2a45bd 100644 --- a/src/common/__tests__/user-store.test.ts +++ b/src/common/__tests__/user-store.test.ts @@ -32,6 +32,7 @@ import type { DiContainer } from "@ogre-tools/injectable"; import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; import type { ClusterStoreModel } from "../cluster-store/cluster-store"; import { defaultTheme } from "../vars"; +import writeFileInjectable from "../fs/write-file.injectable"; console = new Console(stdout, stderr); @@ -46,6 +47,7 @@ describe("user store tests", () => { mainDi = dis.mainDi; + mainDi.override(writeFileInjectable, () => () => undefined); mainDi.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); await dis.runSetups(); diff --git a/src/common/fs/write-file.injectable.ts b/src/common/fs/write-file.injectable.ts new file mode 100644 index 000000000000..d7e536fb52e3 --- /dev/null +++ b/src/common/fs/write-file.injectable.ts @@ -0,0 +1,25 @@ +/** + * 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 path from "path"; +import fsInjectable from "./fs.injectable"; + +const writeFileInjectable = getInjectable({ + id: "write-file", + + instantiate: (di) => { + const { writeFile, ensureDir } = di.inject(fsInjectable); + + return async (filePath: string, content: string | Buffer) => { + await ensureDir(path.dirname(filePath), { mode: 0o755 }); + + await writeFile(filePath, content, { + encoding: "utf-8", + }); + }; + }, +}); + +export default writeFileInjectable; diff --git a/src/main/__test__/cluster.test.ts b/src/main/__test__/cluster.test.ts index 4fa5f5cdd3bd..2b6ab2eb8bf3 100644 --- a/src/main/__test__/cluster.test.ts +++ b/src/main/__test__/cluster.test.ts @@ -41,6 +41,7 @@ import type { ClusterModel } from "../../common/cluster-types"; import { createClusterInjectionToken } from "../../common/cluster/create-cluster-injection-token"; import authorizationReviewInjectable from "../../common/cluster/authorization-review.injectable"; import listNamespacesInjectable from "../../common/cluster/list-namespaces.injectable"; +import createContextHandlerInjectable from "../context-handler/create-context-handler.injectable"; console = new Console(process.stdout, process.stderr); // fix mockFS @@ -81,6 +82,9 @@ describe("create clusters", () => { di.override(authorizationReviewInjectable, () => () => () => Promise.resolve(true)); di.override(listNamespacesInjectable, () => () => () => Promise.resolve([ "default" ])); + di.override(createContextHandlerInjectable, () => () => { + throw new Error("you should never come here"); + }); createCluster = di.inject(createClusterInjectionToken); diff --git a/src/main/__test__/context-handler.test.ts b/src/main/__test__/context-handler.test.ts index 191a129dbee4..4563707d0ce2 100644 --- a/src/main/__test__/context-handler.test.ts +++ b/src/main/__test__/context-handler.test.ts @@ -10,6 +10,8 @@ import mockFs from "mock-fs"; import { getDiForUnitTesting } from "../getDiForUnitTesting"; import createContextHandlerInjectable from "../context-handler/create-context-handler.injectable"; import type { Cluster } from "../../common/cluster/cluster"; +import kubeAuthProxyCaInjectable from "../kube-auth-proxy/kube-auth-proxy-ca.injectable"; +import createKubeAuthProxyInjectable from "../kube-auth-proxy/create-kube-auth-proxy.injectable"; jest.mock("electron", () => ({ app: { @@ -80,6 +82,9 @@ describe("ContextHandler", () => { "tmp": {}, }); + di.override(createKubeAuthProxyInjectable, () => ({} as any)); + di.override(kubeAuthProxyCaInjectable, () => ({} as any)); + await di.runSetups(); createContextHandler = di.inject(createContextHandlerInjectable); diff --git a/src/main/__test__/kube-auth-proxy.test.ts b/src/main/__test__/kube-auth-proxy.test.ts index 4c006ecec941..162cf823eea0 100644 --- a/src/main/__test__/kube-auth-proxy.test.ts +++ b/src/main/__test__/kube-auth-proxy.test.ts @@ -50,6 +50,9 @@ import { getDiForUnitTesting } from "../getDiForUnitTesting"; import createKubeAuthProxyInjectable from "../kube-auth-proxy/create-kube-auth-proxy.injectable"; import { createClusterInjectionToken } from "../../common/cluster/create-cluster-injection-token"; import path from "path"; +import spawnInjectable from "../child-process/spawn.injectable"; +import kubeAuthProxyCaInjectable from "../kube-auth-proxy/kube-auth-proxy-ca.injectable"; +import createKubeAuthProxyCertFilesInjectable from "../kube-auth-proxy/create-kube-auth-proxy-cert-files.injectable"; console = new Console(stdout, stderr); @@ -92,6 +95,10 @@ describe("kube auth proxy tests", () => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); + di.override(spawnInjectable, () => mockSpawn); + di.override(createKubeAuthProxyCertFilesInjectable, () => ({} as any)); + di.override(kubeAuthProxyCaInjectable, () => ({} as any)); + mockFs(mockMinikubeConfig); await di.runSetups(); @@ -130,7 +137,7 @@ describe("kube auth proxy tests", () => { beforeEach(async () => { mockedCP = mock(); listeners = new EventEmitter(); - + jest.spyOn(Kubectl.prototype, "checkBinary").mockReturnValueOnce(Promise.resolve(true)); jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValueOnce(Promise.resolve(false)); mockedCP.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): ChildProcess => { diff --git a/src/main/__test__/kubeconfig-manager.test.ts b/src/main/__test__/kubeconfig-manager.test.ts index 53808c6ce9cc..263d6be99645 100644 --- a/src/main/__test__/kubeconfig-manager.test.ts +++ b/src/main/__test__/kubeconfig-manager.test.ts @@ -40,15 +40,18 @@ import * as path from "path"; import createKubeconfigManagerInjectable from "../kubeconfig-manager/create-kubeconfig-manager.injectable"; import { createClusterInjectionToken } from "../../common/cluster/create-cluster-injection-token"; import directoryForTempInjectable from "../../common/app-paths/directory-for-temp/directory-for-temp.injectable"; +import createContextHandlerInjectable from "../context-handler/create-context-handler.injectable"; +import type { DiContainer } from "@ogre-tools/injectable"; console = new Console(process.stdout, process.stderr); // fix mockFS describe("kubeconfig manager tests", () => { - let cluster: Cluster; + let clusterFake: Cluster; let createKubeconfigManager: (cluster: Cluster) => KubeconfigManager; + let di: DiContainer; beforeEach(async () => { - const di = getDiForUnitTesting({ doGeneralOverrides: true }); + di = getDiForUnitTesting({ doGeneralOverrides: true }); di.override(directoryForTempInjectable, () => "some-directory-for-temp"); @@ -78,17 +81,21 @@ describe("kubeconfig manager tests", () => { await di.runSetups(); + di.override(createContextHandlerInjectable, () => () => { + throw new Error("you should never come here"); + }); + const createCluster = di.inject(createClusterInjectionToken); createKubeconfigManager = di.inject(createKubeconfigManagerInjectable); - cluster = createCluster({ + clusterFake = createCluster({ id: "foo", contextName: "minikube", kubeConfigPath: "minikube-config.yml", }); - cluster.contextHandler = { + clusterFake.contextHandler = { ensureServer: () => Promise.resolve(), } as any; @@ -100,7 +107,7 @@ describe("kubeconfig manager tests", () => { }); it("should create 'temp' kube config with proxy", async () => { - const kubeConfManager = createKubeconfigManager(cluster); + const kubeConfManager = createKubeconfigManager(clusterFake); expect(logger.error).not.toBeCalled(); expect(await kubeConfManager.getPath()).toBe(`some-directory-for-temp${path.sep}kubeconfig-foo`); @@ -115,7 +122,7 @@ describe("kubeconfig manager tests", () => { }); it("should remove 'temp' kube config on unlink and remove reference from inside class", async () => { - const kubeConfManager = createKubeconfigManager(cluster); + const kubeConfManager = createKubeconfigManager(clusterFake); const configPath = await kubeConfManager.getPath(); diff --git a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts index f04458c3c86e..75c58aa036e2 100644 --- a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts +++ b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts @@ -16,6 +16,8 @@ import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import { createClusterInjectionToken } from "../../../common/cluster/create-cluster-injection-token"; import directoryForKubeConfigsInjectable from "../../../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable"; +import kubeAuthProxyCaInjectable from "../../kube-auth-proxy/kube-auth-proxy-ca.injectable"; +import createKubeAuthProxyCertFilesInjectable from "../../kube-auth-proxy/create-kube-auth-proxy-cert-files.injectable"; jest.mock("electron", () => ({ @@ -40,6 +42,9 @@ describe("kubeconfig-sync.source tests", () => { beforeEach(async () => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); + di.override(kubeAuthProxyCaInjectable, () => Promise.resolve(Buffer.from("ca"))); + di.override(createKubeAuthProxyCertFilesInjectable, () => ({} as any)); + mockFs(); await di.runSetups(); diff --git a/src/main/child-process/spawn.injectable.ts b/src/main/child-process/spawn.injectable.ts new file mode 100644 index 000000000000..d313e19d9594 --- /dev/null +++ b/src/main/child-process/spawn.injectable.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 { getInjectable } from "@ogre-tools/injectable"; +import { spawn } from "child_process"; + +const spawnInjectable = getInjectable({ + id: "spawn", + + instantiate: () => { + return spawn; + }, + causesSideEffects: true, +}); + +export default spawnInjectable; diff --git a/src/main/context-handler/context-handler.ts b/src/main/context-handler/context-handler.ts index 5287828b5b24..d08d5ace1ff3 100644 --- a/src/main/context-handler/context-handler.ts +++ b/src/main/context-handler/context-handler.ts @@ -12,6 +12,7 @@ import url, { UrlWithStringQuery } from "url"; import { CoreV1Api } from "@kubernetes/client-node"; import logger from "../logger"; import type { KubeAuthProxy } from "../kube-auth-proxy/kube-auth-proxy"; +import type { CreateKubeAuthProxy } from "../kube-auth-proxy/create-kube-auth-proxy.injectable"; export interface PrometheusDetails { prometheusPath: string; @@ -26,7 +27,8 @@ interface PrometheusServicePreferences { } interface Dependencies { - createKubeAuthProxy: (cluster: Cluster, environmentVariables: NodeJS.ProcessEnv) => KubeAuthProxy; + createKubeAuthProxy: CreateKubeAuthProxy; + authProxyCa: Promise; } export class ContextHandler { @@ -118,7 +120,11 @@ export class ContextHandler { await this.ensureServer(); const path = this.clusterUrl.path !== "/" ? this.clusterUrl.path : ""; - return `http://127.0.0.1:${this.kubeAuthProxy.port}${this.kubeAuthProxy.apiPrefix}${path}`; + return `https://127.0.0.1:${this.kubeAuthProxy.port}${this.kubeAuthProxy.apiPrefix}${path}`; + } + + async resolveAuthProxyCa() { + return this.dependencies.authProxyCa; } async getApiTarget(isLongRunningRequest = false): Promise { @@ -132,10 +138,23 @@ export class ContextHandler { } protected async newApiTarget(timeout: number): Promise { + await this.ensureServer(); + + const caFileContents = await this.resolveAuthProxyCa(); + const clusterPath = this.clusterUrl.path !== "/" ? this.clusterUrl.path : ""; + const apiPrefix = `${this.kubeAuthProxy.apiPrefix}${clusterPath}`; + return { - target: await this.resolveAuthProxyUrl(), + target: { + protocol: "https:", + host: "127.0.0.1", + port: this.kubeAuthProxy.port, + path: apiPrefix, + ca: caFileContents.toString(), + }, changeOrigin: true, timeout, + secure: true, headers: { "Host": this.clusterUrl.hostname, }, diff --git a/src/main/context-handler/create-context-handler.injectable.ts b/src/main/context-handler/create-context-handler.injectable.ts index 47b935ceaa07..c08bd4276aea 100644 --- a/src/main/context-handler/create-context-handler.injectable.ts +++ b/src/main/context-handler/create-context-handler.injectable.ts @@ -6,13 +6,17 @@ import { getInjectable } from "@ogre-tools/injectable"; import type { Cluster } from "../../common/cluster/cluster"; import { ContextHandler } from "./context-handler"; import createKubeAuthProxyInjectable from "../kube-auth-proxy/create-kube-auth-proxy.injectable"; +import kubeAuthProxyCaInjectable from "../kube-auth-proxy/kube-auth-proxy-ca.injectable"; const createContextHandlerInjectable = getInjectable({ id: "create-context-handler", instantiate: (di) => { + const authProxyCa = di.inject(kubeAuthProxyCaInjectable); + const dependencies = { createKubeAuthProxy: di.inject(createKubeAuthProxyInjectable), + authProxyCa, }; return (cluster: Cluster) => new ContextHandler(dependencies, cluster); diff --git a/src/main/getDiForUnitTesting.ts b/src/main/getDiForUnitTesting.ts index af4ad236747b..777fc478928a 100644 --- a/src/main/getDiForUnitTesting.ts +++ b/src/main/getDiForUnitTesting.ts @@ -16,6 +16,7 @@ import writeJsonFileInjectable from "../common/fs/write-json-file.injectable"; import readJsonFileInjectable from "../common/fs/read-json-file.injectable"; import readFileInjectable from "../common/fs/read-file.injectable"; import directoryForBundledBinariesInjectable from "../common/app-paths/directory-for-bundled-binaries/directory-for-bundled-binaries.injectable"; +import spawnInjectable from "./child-process/spawn.injectable"; export const getDiForUnitTesting = ( { doGeneralOverrides } = { doGeneralOverrides: false }, @@ -46,6 +47,13 @@ export const getDiForUnitTesting = ( di.override(appNameInjectable, () => "some-electron-app-name"); di.override(registerChannelInjectable, () => () => undefined); di.override(directoryForBundledBinariesInjectable, () => "some-bin-directory"); + di.override(spawnInjectable, () => () => { + return { + stderr: { on: jest.fn(), removeAllListeners: jest.fn() }, + stdout: { on: jest.fn(), removeAllListeners: jest.fn() }, + on: jest.fn(), + } as any; + }); di.override(writeJsonFileInjectable, () => () => { throw new Error("Tried to write JSON file to file system without specifying explicit override."); diff --git a/src/main/index.ts b/src/main/index.ts index 2366a8036a1b..d60d0ea4e626 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -39,7 +39,7 @@ import { WeblinkStore } from "../common/weblink-store"; import { SentryInit } from "../common/sentry"; import { ensureDir } from "fs-extra"; import { initMenu } from "./menu/menu"; -import { kubeApiRequest } from "./proxy-functions"; +import { kubeApiUpgradeRequest } from "./proxy-functions"; import { initTray } from "./tray/tray"; import { ShellSession } from "./shell-session/shell-session"; import { getDi } from "./getDi"; @@ -62,9 +62,11 @@ const di = getDi(); app.setName(appName); -di.runSetups().then(() => { - injectSystemCAs(); +app.on("ready", async () => { + await di.runSetups(); + injectSystemCAs(); + const onCloseCleanup = disposer(); const onQuitCleanup = disposer(); @@ -124,232 +126,230 @@ di.runSetups().then(() => { WindowManager.getInstance(false)?.ensureMainWindow(); }); - app.on("ready", async () => { - const directoryForExes = di.inject(directoryForExesInjectable); - - logger.info(`🚀 Starting ${productName} from "${directoryForExes}"`); - logger.info("🐚 Syncing shell environment"); - await shellSync(); - - powerMonitor.on("shutdown", () => app.exit()); - - registerFileProtocol("static", __static); - - PrometheusProviderRegistry.createInstance(); - initializers.initPrometheusProviderRegistry(); - - /** - * The following sync MUST be done before HotbarStore creation, because that - * store has migrations that will remove items that previous migrations add - * if this is not present - */ - syncGeneralEntities(); - - logger.info("💾 Loading stores"); + app.on("activate", (event, hasVisibleWindows) => { + logger.info("APP:ACTIVATE", { hasVisibleWindows }); - const userStore = di.inject(userStoreInjectable); + if (!hasVisibleWindows) { + WindowManager.getInstance(false)?.ensureMainWindow(false); + } + }); - userStore.startMainReactions(); + /** + * This variable should is used so that `autoUpdater.installAndQuit()` works + */ + let blockQuit = !isIntegrationTesting; - // ClusterStore depends on: UserStore - const clusterStore = di.inject(clusterStoreInjectable); + autoUpdater.on("before-quit-for-update", () => { + logger.debug("Unblocking quit for update"); + blockQuit = false; + }); - clusterStore.provideInitialFromMain(); + app.on("will-quit", (event) => { + logger.debug("will-quit message"); - // HotbarStore depends on: ClusterStore - HotbarStore.createInstance(); + // This is called when the close button of the main window is clicked - WeblinkStore.createInstance(); - syncWeblinks(); + logger.info("APP:QUIT"); + appEventBus.emit({ name: "app", action: "close" }); + ClusterManager.getInstance(false)?.stop(); // close cluster connections - HelmRepoManager.createInstance(); // create the instance + const kubeConfigSyncManager = di.inject(kubeconfigSyncManagerInjectable); - const router = di.inject(routerInjectable); - const shellApiRequest = di.inject(shellApiRequestInjectable); + kubeConfigSyncManager.stopSync(); - const lensProxy = LensProxy.createInstance(router, httpProxy.createProxy(), { - getClusterForRequest: (req) => ClusterManager.getInstance().getClusterForRequest(req), - kubeApiRequest, - shellApiRequest, - }); + onCloseCleanup(); - ClusterManager.createInstance().init(); + // This is set to false here so that LPRM can wait to send future lens:// + // requests until after it loads again + lensProtocolRouterMain.rendererLoaded = false; - initializers.initClusterMetadataDetectors(); + if (blockQuit) { + // Quit app on Cmd+Q (MacOS) - try { - logger.info("🔌 Starting LensProxy"); - await lensProxy.listen(); // lensProxy.port available - } catch (error) { - dialog.showErrorBox("Lens Error", `Could not start proxy: ${error?.message || "unknown error"}`); + event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.) - return app.exit(); + return; // skip exit to make tray work, to quit go to app's global menu or tray's menu } - // test proxy connection - try { - logger.info("🔎 Testing LensProxy connection ..."); - const versionFromProxy = await getAppVersionFromProxyServer(lensProxy.port); + lensProtocolRouterMain.cleanup(); + onQuitCleanup(); + }); - if (getAppVersion() !== versionFromProxy) { - logger.error("Proxy server responded with invalid response"); + app.on("open-url", (event, rawUrl) => { + logger.debug("open-url message"); - return app.exit(); - } + // lens:// protocol handler + event.preventDefault(); + lensProtocolRouterMain.route(rawUrl); + }); - logger.info("⚡ LensProxy connection OK"); - } catch (error) { - logger.error(`🛑 LensProxy: failed connection test: ${error}`); + logger.debug("[APP-MAIN] waiting for 'ready' and other messages"); - const hostsPath = isWindows - ? "C:\\windows\\system32\\drivers\\etc\\hosts" - : "/etc/hosts"; - const message = [ - `Failed connection test: ${error}`, - "Check to make sure that no other versions of Lens are running", - `Check ${hostsPath} to make sure that it is clean and that the localhost loopback is at the top and set to 127.0.0.1`, - "If you have HTTP_PROXY or http_proxy set in your environment, make sure that the localhost and the ipv4 loopback address 127.0.0.1 are added to the NO_PROXY environment variable.", - ]; + const directoryForExes = di.inject(directoryForExesInjectable); - dialog.showErrorBox("Lens Proxy Error", message.join("\n\n")); + logger.info(`🚀 Starting ${productName} from "${directoryForExes}"`); + logger.info("🐚 Syncing shell environment"); + await shellSync(); - return app.exit(); - } + powerMonitor.on("shutdown", () => app.exit()); - const extensionLoader = di.inject(extensionLoaderInjectable); + registerFileProtocol("static", __static); - extensionLoader.init(); + PrometheusProviderRegistry.createInstance(); + initializers.initPrometheusProviderRegistry(); - const extensionDiscovery = di.inject(extensionDiscoveryInjectable); + /** + * The following sync MUST be done before HotbarStore creation, because that + * store has migrations that will remove items that previous migrations add + * if this is not present + */ + syncGeneralEntities(); - extensionDiscovery.init(); + logger.info("💾 Loading stores"); - // Start the app without showing the main window when auto starting on login - // (On Windows and Linux, we get a flag. On MacOS, we get special API.) - const startHidden = process.argv.includes("--hidden") || (isMac && app.getLoginItemSettings().wasOpenedAsHidden); + const userStore = di.inject(userStoreInjectable); - logger.info("🖥️ Starting WindowManager"); - const windowManager = WindowManager.createInstance(); - const menuItems = di.inject(electronMenuItemsInjectable); - const trayMenuItems = di.inject(trayMenuItemsInjectable); + userStore.startMainReactions(); - onQuitCleanup.push( - initMenu(windowManager, menuItems), - initTray(windowManager, trayMenuItems), - () => ShellSession.cleanup(), - ); + // ClusterStore depends on: UserStore + const clusterStore = di.inject(clusterStoreInjectable); - installDeveloperTools(); + clusterStore.provideInitialFromMain(); - if (!startHidden) { - windowManager.ensureMainWindow(); - } + // HotbarStore depends on: ClusterStore + HotbarStore.createInstance(); - ipcMainOn(IpcRendererNavigationEvents.LOADED, async () => { - onCloseCleanup.push(startCatalogSyncToRenderer(catalogEntityRegistry)); + WeblinkStore.createInstance(); - const directoryForKubeConfigs = di.inject(directoryForKubeConfigsInjectable); + syncWeblinks(); - await ensureDir(directoryForKubeConfigs); + HelmRepoManager.createInstance(); // create the instance - const kubeConfigSyncManager = di.inject(kubeconfigSyncManagerInjectable); + const router = di.inject(routerInjectable); + const shellApiRequest = di.inject(shellApiRequestInjectable); - kubeConfigSyncManager.startSync(); + const lensProxy = LensProxy.createInstance(router, httpProxy.createProxy(), { + getClusterForRequest: (req) => ClusterManager.getInstance().getClusterForRequest(req), + kubeApiUpgradeRequest, + shellApiRequest, + }); - startUpdateChecking(); - lensProtocolRouterMain.rendererLoaded = true; - }); + ClusterManager.createInstance().init(); - logger.info("🧩 Initializing extensions"); + initializers.initClusterMetadataDetectors(); - // call after windowManager to see splash earlier - try { - const extensions = await extensionDiscovery.load(); + try { + logger.info("🔌 Starting LensProxy"); + await lensProxy.listen(); // lensProxy.port available + } catch (error) { + dialog.showErrorBox("Lens Error", `Could not start proxy: ${error?.message || "unknown error"}`); - // Start watching after bundled extensions are loaded - extensionDiscovery.watchExtensions(); + return app.exit(); + } - // Subscribe to extensions that are copied or deleted to/from the extensions folder - extensionDiscovery.events - .on("add", (extension: InstalledExtension) => { - extensionLoader.addExtension(extension); - }) - .on("remove", (lensExtensionId: LensExtensionId) => { - extensionLoader.removeExtension(lensExtensionId); - }); + // test proxy connection + try { + logger.info("🔎 Testing LensProxy connection ..."); + const versionFromProxy = await getAppVersionFromProxyServer(lensProxy.port); - extensionLoader.initExtensions(extensions); - } catch (error) { - dialog.showErrorBox("Lens Error", `Could not load extensions${error?.message ? `: ${error.message}` : ""}`); - console.error(error); - console.trace(); + if (getAppVersion() !== versionFromProxy) { + logger.error("Proxy server responded with invalid response"); + + return app.exit(); } - setTimeout(() => { - appEventBus.emit({ name: "service", action: "start" }); - }, 1000); - }); + logger.info("⚡ LensProxy connection OK"); + } catch (error) { + logger.error(`🛑 LensProxy: failed connection test: ${error}`); - app.on("activate", (event, hasVisibleWindows) => { - logger.info("APP:ACTIVATE", { hasVisibleWindows }); + const hostsPath = isWindows + ? "C:\\windows\\system32\\drivers\\etc\\hosts" + : "/etc/hosts"; + const message = [ + `Failed connection test: ${error}`, + "Check to make sure that no other versions of Lens are running", + `Check ${hostsPath} to make sure that it is clean and that the localhost loopback is at the top and set to 127.0.0.1`, + "If you have HTTP_PROXY or http_proxy set in your environment, make sure that the localhost and the ipv4 loopback address 127.0.0.1 are added to the NO_PROXY environment variable.", + ]; - if (!hasVisibleWindows) { - WindowManager.getInstance(false)?.ensureMainWindow(false); - } - }); + dialog.showErrorBox("Lens Proxy Error", message.join("\n\n")); - /** - * This variable should is used so that `autoUpdater.installAndQuit()` works - */ - let blockQuit = !isIntegrationTesting; + return app.exit(); + } - autoUpdater.on("before-quit-for-update", () => { - logger.debug("Unblocking quit for update"); - blockQuit = false; - }); + const extensionLoader = di.inject(extensionLoaderInjectable); - app.on("will-quit", (event) => { - logger.debug("will-quit message"); + extensionLoader.init(); - // This is called when the close button of the main window is clicked + const extensionDiscovery = di.inject(extensionDiscoveryInjectable); + extensionDiscovery.init(); - logger.info("APP:QUIT"); - appEventBus.emit({ name: "app", action: "close" }); - ClusterManager.getInstance(false)?.stop(); // close cluster connections + // Start the app without showing the main window when auto starting on login + // (On Windows and Linux, we get a flag. On MacOS, we get special API.) + const startHidden = process.argv.includes("--hidden") || (isMac && app.getLoginItemSettings().wasOpenedAsHidden); - const kubeConfigSyncManager = di.inject(kubeconfigSyncManagerInjectable); + logger.info("🖥️ Starting WindowManager"); + const windowManager = WindowManager.createInstance(); + const menuItems = di.inject(electronMenuItemsInjectable); + const trayMenuItems = di.inject(trayMenuItemsInjectable); - kubeConfigSyncManager.stopSync(); + onQuitCleanup.push( + initMenu(windowManager, menuItems), + initTray(windowManager, trayMenuItems), + () => ShellSession.cleanup(), + ); - onCloseCleanup(); + installDeveloperTools(); - // This is set to false here so that LPRM can wait to send future lens:// - // requests until after it loads again - lensProtocolRouterMain.rendererLoaded = false; + if (!startHidden) { + windowManager.ensureMainWindow(); + } - if (blockQuit) { - // Quit app on Cmd+Q (MacOS) + ipcMainOn(IpcRendererNavigationEvents.LOADED, async () => { + onCloseCleanup.push(startCatalogSyncToRenderer(catalogEntityRegistry)); - event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.) + const directoryForKubeConfigs = di.inject(directoryForKubeConfigsInjectable); - return; // skip exit to make tray work, to quit go to app's global menu or tray's menu - } + await ensureDir(directoryForKubeConfigs); - lensProtocolRouterMain.cleanup(); - onQuitCleanup(); - }); + const kubeConfigSyncManager = di.inject(kubeconfigSyncManagerInjectable); - app.on("open-url", (event, rawUrl) => { - logger.debug("open-url message"); + kubeConfigSyncManager.startSync(); - // lens:// protocol handler - event.preventDefault(); - lensProtocolRouterMain.route(rawUrl); + startUpdateChecking(); + lensProtocolRouterMain.rendererLoaded = true; }); - logger.debug("[APP-MAIN] waiting for 'ready' and other messages"); + logger.info("🧩 Initializing extensions"); + + // call after windowManager to see splash earlier + try { + const extensions = await extensionDiscovery.load(); + + // Start watching after bundled extensions are loaded + extensionDiscovery.watchExtensions(); + + // Subscribe to extensions that are copied or deleted to/from the extensions folder + extensionDiscovery.events + .on("add", (extension: InstalledExtension) => { + extensionLoader.addExtension(extension); + }) + .on("remove", (lensExtensionId: LensExtensionId) => { + extensionLoader.removeExtension(lensExtensionId); + }); + + extensionLoader.initExtensions(extensions); + } catch (error) { + dialog.showErrorBox("Lens Error", `Could not load extensions${error?.message ? `: ${error.message}` : ""}`); + console.error(error); + console.trace(); + } + + setTimeout(() => { + appEventBus.emit({ name: "service", action: "start" }); + }, 1000); }); /** diff --git a/src/main/kube-auth-proxy/create-kube-auth-proxy-cert-files.injectable.ts b/src/main/kube-auth-proxy/create-kube-auth-proxy-cert-files.injectable.ts new file mode 100644 index 000000000000..297a43daa4c2 --- /dev/null +++ b/src/main/kube-auth-proxy/create-kube-auth-proxy-cert-files.injectable.ts @@ -0,0 +1,26 @@ +/** + * 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 * as selfsigned from "selfsigned"; +import { createKubeAuthProxyCertFiles } from "./create-kube-auth-proxy-cert-files"; +import writeFileInjectable from "../../common/fs/write-file.injectable"; +import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import path from "path"; + +const createKubeAuthProxyCertFilesInjectable = getInjectable({ + id: "create-kube-auth-proxy-cert-files", + + instantiate: async (di) => { + const userData = di.inject(directoryForUserDataInjectable); + const certPath = path.join(userData, "kube-auth-proxy"); + + return createKubeAuthProxyCertFiles(certPath, { + generate: selfsigned.generate, + writeFile: di.inject(writeFileInjectable), + }); + }, +}); + +export default createKubeAuthProxyCertFilesInjectable; diff --git a/src/main/kube-auth-proxy/create-kube-auth-proxy-cert-files.ts b/src/main/kube-auth-proxy/create-kube-auth-proxy-cert-files.ts new file mode 100644 index 000000000000..325495dc7aae --- /dev/null +++ b/src/main/kube-auth-proxy/create-kube-auth-proxy-cert-files.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import path from "path"; +import type * as selfsigned from "selfsigned"; + +type SelfSignedGenerate = typeof selfsigned.generate; + +interface CreateKubeAuthProxyCertificateFilesDependencies { + generate: SelfSignedGenerate; + writeFile: (path: string, content: string | Buffer) => Promise; +} + +function getKubeAuthProxyCertificate(generate: SelfSignedGenerate): selfsigned.SelfSignedCert { + const opts = [ + { name: "commonName", value: "Lens Certificate Authority" }, + { name: "organizationName", value: "Lens" }, + ]; + + return generate(opts, { + keySize: 2048, + algorithm: "sha256", + days: 365, + extensions: [ + { name: "basicConstraints", cA: true }, + { name: "subjectAltName", altNames: [ + { type: 2, value: "localhost" }, + { type: 7, ip: "127.0.0.1" }, + ] }, + ], + }); +} + +export async function createKubeAuthProxyCertFiles(dir: string, dependencies: CreateKubeAuthProxyCertificateFilesDependencies): Promise { + const cert = getKubeAuthProxyCertificate(dependencies.generate); + + await dependencies.writeFile(path.join(dir, "proxy.key"), cert.private); + await dependencies.writeFile(path.join(dir, "proxy.crt"), cert.cert); + + return dir; +} diff --git a/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts b/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts index 3e3327dd467a..b566782197eb 100644 --- a/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts +++ b/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts @@ -8,14 +8,20 @@ import type { Cluster } from "../../common/cluster/cluster"; import path from "path"; import { getBinaryName } from "../../common/vars"; import directoryForBundledBinariesInjectable from "../../common/app-paths/directory-for-bundled-binaries/directory-for-bundled-binaries.injectable"; +import spawnInjectable from "../child-process/spawn.injectable"; +import createKubeAuthProxyCertFilesInjectable from "./create-kube-auth-proxy-cert-files.injectable"; + +export type CreateKubeAuthProxy = (cluster: Cluster, environmentVariables: NodeJS.ProcessEnv) => KubeAuthProxy; const createKubeAuthProxyInjectable = getInjectable({ id: "create-kube-auth-proxy", - instantiate: (di) => { + instantiate: (di): CreateKubeAuthProxy => { const binaryName = getBinaryName("lens-k8s-proxy"); const dependencies: KubeAuthProxyDependencies = { proxyBinPath: path.join(di.inject(directoryForBundledBinariesInjectable), binaryName), + proxyCertPath: di.inject(createKubeAuthProxyCertFilesInjectable), + spawn: di.inject(spawnInjectable), }; return (cluster: Cluster, environmentVariables: NodeJS.ProcessEnv) => ( diff --git a/src/main/kube-auth-proxy/kube-auth-proxy-ca.injectable.ts b/src/main/kube-auth-proxy/kube-auth-proxy-ca.injectable.ts new file mode 100644 index 000000000000..37aaafeeee5e --- /dev/null +++ b/src/main/kube-auth-proxy/kube-auth-proxy-ca.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 path from "path"; +import readFileInjectable from "../../common/fs/read-file.injectable"; +import createKubeAuthProxyCertFilesInjectable from "./create-kube-auth-proxy-cert-files.injectable"; + +const kubeAuthProxyCaInjectable = getInjectable({ + id: "kube-auth-proxy-ca", + + instantiate: async (di) => { + const certPath = await di.inject(createKubeAuthProxyCertFilesInjectable); + + const readFile = di.inject(readFileInjectable); + + return readFile(path.join(certPath, "proxy.crt")); + }, +}); + +export default kubeAuthProxyCaInjectable; diff --git a/src/main/kube-auth-proxy/kube-auth-proxy.ts b/src/main/kube-auth-proxy/kube-auth-proxy.ts index 13c1bc1a8214..f25ea0f97bc2 100644 --- a/src/main/kube-auth-proxy/kube-auth-proxy.ts +++ b/src/main/kube-auth-proxy/kube-auth-proxy.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { ChildProcess, spawn } from "child_process"; +import type { ChildProcess, spawn } from "child_process"; import { waitUntilUsed } from "tcp-port-used"; import { randomBytes } from "crypto"; import type { Cluster } from "../../common/cluster/cluster"; @@ -15,6 +15,8 @@ const startingServeRegex = /starting to serve on (?
.+)/i; export interface KubeAuthProxyDependencies { proxyBinPath: string; + proxyCertPath: Promise; + spawn: typeof spawn; } export class KubeAuthProxy { @@ -42,13 +44,15 @@ export class KubeAuthProxy { } const proxyBin = this.dependencies.proxyBinPath; + const certPath = await this.dependencies.proxyCertPath; - this.proxyProcess = spawn(proxyBin, [], { + this.proxyProcess = this.dependencies.spawn(proxyBin, [], { env: { ...this.env, KUBECONFIG: this.cluster.kubeConfigPath, KUBECONFIG_CONTEXT: this.cluster.contextName, API_PREFIX: this.apiPrefix, + CERT_PATH: certPath, }, }); this.proxyProcess.on("error", (error) => { @@ -66,11 +70,15 @@ export class KubeAuthProxy { this.exit(); }); - this.proxyProcess.stderr.on("data", (data) => { + this.proxyProcess.stderr.on("data", (data: Buffer) => { + if (data.includes("http: TLS handshake error")) { + return; + } + this.cluster.broadcastConnectUpdate(data.toString(), true); }); - this.proxyProcess.stdout.on("data", (data: any) => { + this.proxyProcess.stdout.on("data", (data: Buffer) => { if (typeof this._port === "number") { this.cluster.broadcastConnectUpdate(data.toString()); } diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy.ts index fb6d7fcc0fed..7f2fbd728132 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy.ts @@ -22,7 +22,7 @@ type GetClusterForRequest = (req: http.IncomingMessage) => Cluster | null; export interface LensProxyFunctions { getClusterForRequest: GetClusterForRequest; shellApiRequest: (args: ProxyApiRequestArgs) => void | Promise; - kubeApiRequest: (args: ProxyApiRequestArgs) => void | Promise; + kubeApiUpgradeRequest: (args: ProxyApiRequestArgs) => void | Promise; } const watchParam = "watch"; @@ -61,7 +61,7 @@ export class LensProxy extends Singleton { public port: number; - constructor(protected router: Router, protected proxy: httpProxy, { shellApiRequest, kubeApiRequest, getClusterForRequest }: LensProxyFunctions) { + constructor(protected router: Router, protected proxy: httpProxy, { shellApiRequest, kubeApiUpgradeRequest, getClusterForRequest }: LensProxyFunctions) { super(); this.configureProxy(proxy); @@ -88,7 +88,7 @@ export class LensProxy extends Singleton { return socket.destroy(); } - const reqHandler = isInternal ? shellApiRequest : kubeApiRequest; + const reqHandler = isInternal ? shellApiRequest : kubeApiUpgradeRequest; (async () => reqHandler({ req, socket, head, cluster }))() .catch(error => logger.error("[LENS-PROXY]: failed to handle proxy upgrade", error)); diff --git a/src/main/proxy-functions/index.ts b/src/main/proxy-functions/index.ts index 490e3c4a7496..5d374825eed5 100644 --- a/src/main/proxy-functions/index.ts +++ b/src/main/proxy-functions/index.ts @@ -2,5 +2,5 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -export * from "./kube-api-request"; +export * from "./kube-api-upgrade-request"; export * from "./types"; diff --git a/src/main/proxy-functions/kube-api-request.ts b/src/main/proxy-functions/kube-api-upgrade-request.ts similarity index 81% rename from src/main/proxy-functions/kube-api-request.ts rename to src/main/proxy-functions/kube-api-upgrade-request.ts index 8995d965e5a0..899d22164e31 100644 --- a/src/main/proxy-functions/kube-api-request.ts +++ b/src/main/proxy-functions/kube-api-upgrade-request.ts @@ -4,21 +4,25 @@ */ import { chunk } from "lodash"; -import net from "net"; +import tls 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 kubeApiRequest({ req, socket, head, cluster }: ProxyApiRequestArgs) { +export async function kubeApiUpgradeRequest({ req, socket, head, cluster }: ProxyApiRequestArgs) { const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, ""); + const proxyCa = await cluster.contextHandler.resolveAuthProxyCa(); const apiUrl = url.parse(cluster.apiUrl); const pUrl = url.parse(proxyUrl); - const connectOpts = { port: parseInt(pUrl.port), host: pUrl.hostname }; - const proxySocket = new net.Socket(); + const connectOpts = { + port: parseInt(pUrl.port), + host: pUrl.hostname, + ca: proxyCa, + }; - proxySocket.connect(connectOpts, () => { + const proxySocket = tls.connect(connectOpts, () => { proxySocket.write(`${req.method} ${pUrl.path} HTTP/1.1\r\n`); proxySocket.write(`Host: ${apiUrl.host}\r\n`); diff --git a/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx b/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx index fad58d03c584..f341faa18565 100644 --- a/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx +++ b/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx @@ -15,6 +15,7 @@ import { DeleteClusterDialog } from "../delete-cluster-dialog"; import type { ClusterModel } from "../../../../common/cluster-types"; import { getDisForUnitTesting } from "../../../../test-utils/get-dis-for-unit-testing"; import { createClusterInjectionToken } from "../../../../common/cluster/create-cluster-injection-token"; +import createContextHandlerInjectable from "../../../../main/context-handler/create-context-handler.injectable"; jest.mock("electron", () => ({ app: { @@ -91,6 +92,8 @@ describe("", () => { beforeEach(async () => { const { mainDi, runSetups } = getDisForUnitTesting({ doGeneralOverrides: true }); + mainDi.override(createContextHandlerInjectable, () => () => undefined); + mockFs(); await runSetups(); diff --git a/types/selfsigned.d.ts b/types/selfsigned.d.ts new file mode 100644 index 000000000000..89615f1c0101 --- /dev/null +++ b/types/selfsigned.d.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + + declare module "selfsigned" { + export interface SelfSignedCert { + private: string; + public: string; + cert: string; + } + + type GenerateAttributes = Array; + + interface GenerateOptions { + keySize?: number; + days?: number; + algorithm?: "sha1" | "sha256"; + extensions?: any; + pkcs7?: boolean; + clientCertificate?: boolean; + clientCertificateCN?: string; + } + + export function generate(GenerateAttributes, GenerateOptions): SelfSignedCert; + }