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

Lens proxy with TLS (#6851)

* lens proxy tls support

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* integration test fix

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* don't override getRequestOptions if they are set

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix electronAppInjectable override

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* use runnables on renderer

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* move certificate generation to runnables

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* simplify

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen 2023-01-04 19:18:02 +02:00 committed by GitHub
parent fba93b889e
commit 7052dc0dba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 353 additions and 66 deletions

View File

@ -26,7 +26,7 @@ async function getMainWindow(app: ElectronApplication, timeout = 50_000): Promis
const onWindow = (page: Page) => { const onWindow = (page: Page) => {
console.log(`Page opened: ${page.url()}`); console.log(`Page opened: ${page.url()}`);
if (page.url().startsWith("http://localhost")) { if (page.url().startsWith("https://lens.app")) {
cleanup(); cleanup();
console.log(stdoutBuf); console.log(stdoutBuf);
resolve(page); resolve(page);

View File

@ -93,7 +93,7 @@
"bundledKubectlVersion": "1.23.3", "bundledKubectlVersion": "1.23.3",
"bundledHelmVersion": "3.7.2", "bundledHelmVersion": "3.7.2",
"sentryDsn": "", "sentryDsn": "",
"contentSecurityPolicy": "script-src 'unsafe-eval' 'self'; frame-src http://*.localhost:*/; img-src * data:", "contentSecurityPolicy": "script-src 'unsafe-eval' 'self'; frame-src https://*.lens.app:*/; img-src * data:",
"welcomeRoute": "/welcome" "welcomeRoute": "/welcome"
}, },
"engines": { "engines": {

View File

@ -0,0 +1,9 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { SelfSignedCert } from "selfsigned";
import { getRequestChannel } from "../utils/channel/get-request-channel";
export const lensProxyCertificateChannel = getRequestChannel<void, SelfSignedCert>("request-lens-proxy-certificate");

View File

@ -0,0 +1,19 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getGlobalOverride } from "../test-utils/get-global-override";
import lensProxyCertificateInjectable from "./lens-proxy-certificate.injectable";
export default getGlobalOverride(lensProxyCertificateInjectable, () => {
return {
get: () => ({
public: "<public-data>",
private: "<private-data>",
cert: "<ca-data>",
}),
set: () => {},
};
});

View File

@ -0,0 +1,33 @@
/**
* 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 { SelfSignedCert } from "selfsigned";
const lensProxyCertificateInjectable = getInjectable({
id: "lens-proxy-certificate",
instantiate: () => {
let certState: SelfSignedCert;
const cert = {
get: () => {
if (!certState) {
throw "certificate has not been set";
}
return certState;
},
set: (certificate: SelfSignedCert) => {
if (certState) {
throw "certificate has already been set";
}
certState = certificate;
},
};
return cert;
},
});
export default lensProxyCertificateInjectable;

View File

@ -3,7 +3,9 @@
* 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 { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import { Agent } from "https";
import type { RequestInit } from "node-fetch"; import type { RequestInit } from "node-fetch";
import lensProxyCertificateInjectable from "../certificate/lens-proxy-certificate.injectable";
import fetchInjectable from "../fetch/fetch.injectable"; import fetchInjectable from "../fetch/fetch.injectable";
import loggerInjectable from "../logger.injectable"; import loggerInjectable from "../logger.injectable";
import type { JsonApiConfig, JsonApiData, JsonApiDependencies, JsonApiParams } from "./json-api"; import type { JsonApiConfig, JsonApiData, JsonApiDependencies, JsonApiParams } from "./json-api";
@ -18,8 +20,23 @@ const createJsonApiInjectable = getInjectable({
fetch: di.inject(fetchInjectable), fetch: di.inject(fetchInjectable),
logger: di.inject(loggerInjectable), logger: di.inject(loggerInjectable),
}; };
const lensProxyCert = di.inject(lensProxyCertificateInjectable);
return (config, reqInit) => new JsonApi(deps, config, reqInit); return (config, reqInit) => {
if (!config.getRequestOptions) {
config.getRequestOptions = async () => {
const agent = new Agent({
ca: lensProxyCert.get().cert,
});
return {
agent,
};
};
}
return new JsonApi(deps, config, reqInit);
};
}, },
}); });

View File

@ -51,7 +51,7 @@ const createKubeApiForClusterInjectable = getInjectable({
debug: isDevelopment, debug: isDevelopment,
}, { }, {
headers: { headers: {
"Host": `${cluster.metadata.uid}.localhost:${new URL(apiBase.config.serverAddress).port}`, "Host": `${cluster.metadata.uid}.lens.app:${new URL(apiBase.config.serverAddress).port}`,
}, },
}, },
), ),

View File

@ -3,7 +3,9 @@
* 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 { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import { Agent } from "https";
import type { RequestInit } from "node-fetch"; import type { RequestInit } from "node-fetch";
import lensProxyCertificateInjectable from "../certificate/lens-proxy-certificate.injectable";
import fetchInjectable from "../fetch/fetch.injectable"; import fetchInjectable from "../fetch/fetch.injectable";
import loggerInjectable from "../logger.injectable"; import loggerInjectable from "../logger.injectable";
import type { JsonApiConfig, JsonApiDependencies } from "./json-api"; import type { JsonApiConfig, JsonApiDependencies } from "./json-api";
@ -18,8 +20,23 @@ const createKubeJsonApiInjectable = getInjectable({
fetch: di.inject(fetchInjectable), fetch: di.inject(fetchInjectable),
logger: di.inject(loggerInjectable), logger: di.inject(loggerInjectable),
}; };
const lensProxyCert = di.inject(lensProxyCertificateInjectable);
return (config, reqInit) => new KubeJsonApi(dependencies, config, reqInit); return (config, reqInit) => {
if (!config.getRequestOptions) {
config.getRequestOptions = async () => {
const agent = new Agent({
ca: lensProxyCert.get().cert,
});
return {
agent,
};
};
}
return new KubeJsonApi(dependencies, config, reqInit);
};
}, },
}); });

View File

@ -23,7 +23,7 @@ export interface IKubeApiParsed extends IKubeApiLinkRef {
} }
export function parseKubeApi(path: string): IKubeApiParsed { export function parseKubeApi(path: string): IKubeApiParsed {
const apiPath = new URL(path, "http://localhost").pathname; const apiPath = new URL(path, "https://localhost").pathname;
const [, prefix, ...parts] = apiPath.split("/"); const [, prefix, ...parts] = apiPath.split("/");
const apiPrefix = `/${prefix}`; const apiPrefix = `/${prefix}`;
const [left, right, namespaced] = splitArray(parts, "namespaces"); const [left, right, namespaced] = splitArray(parts, "namespaces");

View File

@ -9,22 +9,22 @@ describe("getClusterIdFromHost", () => {
const clusterFakeId = "fe540901-0bd6-4f6c-b472-bce1559d7c4a"; const clusterFakeId = "fe540901-0bd6-4f6c-b472-bce1559d7c4a";
it("should return undefined for non cluster frame hosts", () => { it("should return undefined for non cluster frame hosts", () => {
expect(getClusterIdFromHost("localhost:45345")).toBeUndefined(); expect(getClusterIdFromHost("lens.app:45345")).toBeUndefined();
}); });
it("should return ClusterId for cluster frame hosts", () => { it("should return ClusterId for cluster frame hosts", () => {
expect(getClusterIdFromHost(`${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId); expect(getClusterIdFromHost(`${clusterFakeId}.lens.app:59110`)).toBe(clusterFakeId);
}); });
it("should return ClusterId for cluster frame hosts with additional subdomains", () => { it("should return ClusterId for cluster frame hosts with additional subdomains", () => {
expect(getClusterIdFromHost(`abc.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId); expect(getClusterIdFromHost(`abc.${clusterFakeId}.lens.app:59110`)).toBe(clusterFakeId);
expect(getClusterIdFromHost(`abc.def.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId); expect(getClusterIdFromHost(`abc.def.${clusterFakeId}.lens.app:59110`)).toBe(clusterFakeId);
expect(getClusterIdFromHost(`abc.def.ghi.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId); expect(getClusterIdFromHost(`abc.def.ghi.${clusterFakeId}.lens.app:59110`)).toBe(clusterFakeId);
expect(getClusterIdFromHost(`abc.def.ghi.jkl.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId); expect(getClusterIdFromHost(`abc.def.ghi.jkl.${clusterFakeId}.lens.app:59110`)).toBe(clusterFakeId);
expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId); expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.${clusterFakeId}.lens.app:59110`)).toBe(clusterFakeId);
expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId); expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.${clusterFakeId}.lens.app:59110`)).toBe(clusterFakeId);
expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.stu.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId); expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.stu.${clusterFakeId}.lens.app:59110`)).toBe(clusterFakeId);
expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.stu.vwx.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId); expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.stu.vwx.${clusterFakeId}.lens.app:59110`)).toBe(clusterFakeId);
expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.stu.vwx.yz.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId); expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.stu.vwx.yz.${clusterFakeId}.lens.app:59110`)).toBe(clusterFakeId);
}); });
}); });

View File

@ -1,17 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import requestPromise from "request-promise-native";
export async function getAppVersionFromProxyServer(proxyPort: number): Promise<string> {
const response = await requestPromise({
method: "GET",
uri: `http://127.0.0.1:${proxyPort}/version`,
resolveWithFullResponse: true,
proxy: undefined,
});
return JSON.parse(response.body).version;
}

View File

@ -0,0 +1,9 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { RequestChannel } from "./request-channel-listener-injection-token";
export const getRequestChannel = <Request, Response>(id: string): RequestChannel<Request, Response> => ({
id,
});

View File

@ -14,7 +14,7 @@ export function getClusterIdFromHost(host: string): ClusterId | undefined {
// e.g host == "%clusterId.localhost:45345" // e.g host == "%clusterId.localhost:45345"
const subDomains = host.split(":")[0].split("."); const subDomains = host.split(":")[0].split(".");
return subDomains.slice(-2, -1)[0]; // ClusterId or undefined return subDomains.slice(-3, -2)[0]; // ClusterId or undefined
} }
/** /**

View File

@ -4,7 +4,6 @@
*/ */
export * from "./abort-controller"; export * from "./abort-controller";
export * from "./app-version";
export * from "./autobind"; export * from "./autobind";
export * from "./camelCase"; export * from "./camelCase";
export * from "./cluster-id-url-parsing"; export * from "./cluster-id-url-parsing";

View File

@ -132,7 +132,7 @@ describe("opening application window using tray", () => {
}); });
it("starts loading of content for the application window", () => { it("starts loading of content for the application window", () => {
expect(callForApplicationWindowHtmlMock).toHaveBeenCalledWith("http://localhost:42"); expect(callForApplicationWindowHtmlMock).toHaveBeenCalledWith("https://lens.app:42");
}); });
describe("given static HTML of application window has not resolved yet, when opening from tray again", () => { describe("given static HTML of application window has not resolved yet, when opening from tray again", () => {

View File

@ -102,7 +102,7 @@ describe("kubeconfig manager tests", () => {
clusterServerUrl, clusterServerUrl,
}); });
jest.spyOn(KubeconfigManager.prototype, "resolveProxyUrl", "get").mockReturnValue("http://127.0.0.1:9191/foo"); jest.spyOn(KubeconfigManager.prototype, "resolveProxyUrl", "get").mockReturnValue("https://127.0.0.1:9191/foo");
kubeConfManager = createKubeconfigManager(clusterFake); kubeConfManager = createKubeconfigManager(clusterFake);
}); });
@ -173,7 +173,10 @@ describe("kubeconfig manager tests", () => {
describe("when writing out new proxy kubeconfig resolves", () => { describe("when writing out new proxy kubeconfig resolves", () => {
beforeEach(async () => { beforeEach(async () => {
await writeFileMock.resolveSpecific( await writeFileMock.resolveSpecific(
["/some-directory-for-temp/kubeconfig-foo", "apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: minikube\nclusters:\n - name: minikube\n cluster:\n server: http://127.0.0.1:9191/foo\ncontexts:\n - name: minikube\n context:\n cluster: minikube\n user: proxy\nusers:\n - name: proxy\n user: {}\n"], [
"/some-directory-for-temp/kubeconfig-foo",
"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: minikube\nclusters:\n - name: minikube\n cluster:\n certificate-authority-data: PGNhLWRhdGE+\n server: https://127.0.0.1:9191/foo\n insecure-skip-tls-verify: false\ncontexts:\n - name: minikube\n context:\n cluster: minikube\n user: proxy\nusers:\n - name: proxy\n user:\n username: lens\n password: fake\n",
],
); );
}); });
@ -299,7 +302,10 @@ describe("kubeconfig manager tests", () => {
describe("when writing out new proxy kubeconfig resolves", () => { describe("when writing out new proxy kubeconfig resolves", () => {
beforeEach(async () => { beforeEach(async () => {
await writeFileMock.resolveSpecific( await writeFileMock.resolveSpecific(
["/some-directory-for-temp/kubeconfig-foo", "apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: minikube\nclusters:\n - name: minikube\n cluster:\n server: http://127.0.0.1:9191/foo\ncontexts:\n - name: minikube\n context:\n cluster: minikube\n user: proxy\nusers:\n - name: proxy\n user: {}\n"], [
"/some-directory-for-temp/kubeconfig-foo",
"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: minikube\nclusters:\n - name: minikube\n cluster:\n certificate-authority-data: PGNhLWRhdGE+\n server: https://127.0.0.1:9191/foo\n insecure-skip-tls-verify: false\ncontexts:\n - name: minikube\n context:\n cluster: minikube\n user: proxy\nusers:\n - name: proxy\n user:\n username: lens\n password: fake\n",
],
); );
}); });

View File

@ -9,4 +9,11 @@ import electronAppInjectable from "./electron-app.injectable";
export default getGlobalOverride(electronAppInjectable, () => ({ export default getGlobalOverride(electronAppInjectable, () => ({
getVersion: () => "6.0.0", getVersion: () => "6.0.0",
setLoginItemSettings: () => {}, setLoginItemSettings: () => {},
commandLine: {
appendArgument: () => {},
appendSwitch: () => {},
getSwitchValue: () => "",
hasSwitch: () => false,
removeSwitch: () => {},
},
} as Partial<Electron.App> as Electron.App)); } as Partial<Electron.App> as Electron.App));

View File

@ -4,10 +4,10 @@
*/ */
import type { RequestPromiseOptions } from "request-promise-native"; import type { RequestPromiseOptions } from "request-promise-native";
import request from "request-promise-native"; import request from "request-promise-native";
import { apiKubePrefix } from "../common/vars";
import type { Cluster } from "../common/cluster/cluster"; import type { Cluster } from "../common/cluster/cluster";
import { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import lensProxyPortInjectable from "./lens-proxy/lens-proxy-port.injectable"; import lensProxyPortInjectable from "./lens-proxy/lens-proxy-port.injectable";
import lensProxyCertificateInjectable from "../common/certificate/lens-proxy-certificate.injectable";
export type K8sRequest = (cluster: Cluster, path: string, options?: RequestPromiseOptions) => Promise<any>; export type K8sRequest = (cluster: Cluster, path: string, options?: RequestPromiseOptions) => Promise<any>;
@ -16,18 +16,19 @@ const k8sRequestInjectable = getInjectable({
instantiate: (di) => { instantiate: (di) => {
const lensProxyPort = di.inject(lensProxyPortInjectable); const lensProxyPort = di.inject(lensProxyPortInjectable);
const lensProxyCertificate = di.inject(lensProxyCertificateInjectable);
return async ( return async (
cluster: Cluster, cluster: Cluster,
path: string, path: string,
options: RequestPromiseOptions = {}, options: RequestPromiseOptions = {},
) => { ) => {
const kubeProxyUrl = `http://localhost:${lensProxyPort.get()}${apiKubePrefix}`; const kubeProxyUrl = `https://127.0.0.1:${lensProxyPort.get()}/${cluster.id}`;
options.ca = lensProxyCertificate.get().cert;
options.headers ??= {}; options.headers ??= {};
options.json ??= true; options.json ??= true;
options.timeout ??= 30000; options.timeout ??= 30000;
options.headers.Host = `${cluster.id}.${new URL(kubeProxyUrl).host}`; // required in ClusterManager.getClusterForRequest()
return request(kubeProxyUrl + path, options); return request(kubeProxyUrl + path, options);
}; };

View File

@ -11,7 +11,7 @@ const apiBaseServerAddressInjectable = getInjectable({
instantiate: (di) => { instantiate: (di) => {
const lensProxyPort = di.inject(lensProxyPortInjectable); const lensProxyPort = di.inject(lensProxyPortInjectable);
return `http://127.0.0.1:${lensProxyPort.get()}`; return `https://127.0.0.1:${lensProxyPort.get()}`;
}, },
injectionToken: apiBaseServerAddressInjectionToken, injectionToken: apiBaseServerAddressInjectionToken,
}); });

View File

@ -14,6 +14,7 @@ import getDirnameOfPathInjectable from "../../common/path/get-dirname.injectable
import pathExistsInjectable from "../../common/fs/path-exists.injectable"; import pathExistsInjectable from "../../common/fs/path-exists.injectable";
import writeFileInjectable from "../../common/fs/write-file.injectable"; import writeFileInjectable from "../../common/fs/write-file.injectable";
import removePathInjectable from "../../common/fs/remove.injectable"; import removePathInjectable from "../../common/fs/remove.injectable";
import lensProxyCertificateInjectable from "../../common/certificate/lens-proxy-certificate.injectable";
export interface KubeConfigManagerInstantiationParameter { export interface KubeConfigManagerInstantiationParameter {
cluster: Cluster; cluster: Cluster;
@ -34,6 +35,7 @@ const createKubeconfigManagerInjectable = getInjectable({
removePath: di.inject(removePathInjectable), removePath: di.inject(removePathInjectable),
pathExists: di.inject(pathExistsInjectable), pathExists: di.inject(pathExistsInjectable),
writeFile: di.inject(writeFileInjectable), writeFile: di.inject(writeFileInjectable),
certificate: di.inject(lensProxyCertificateInjectable).get(),
}; };
return (cluster) => new KubeconfigManager(dependencies, cluster); return (cluster) => new KubeconfigManager(dependencies, cluster);

View File

@ -15,6 +15,7 @@ import type { GetDirnameOfPath } from "../../common/path/get-dirname.injectable"
import type { PathExists } from "../../common/fs/path-exists.injectable"; import type { PathExists } from "../../common/fs/path-exists.injectable";
import type { RemovePath } from "../../common/fs/remove.injectable"; import type { RemovePath } from "../../common/fs/remove.injectable";
import type { WriteFile } from "../../common/fs/write-file.injectable"; import type { WriteFile } from "../../common/fs/write-file.injectable";
import type { SelfSignedCert } from "selfsigned";
export interface KubeconfigManagerDependencies { export interface KubeconfigManagerDependencies {
readonly directoryForTemp: string; readonly directoryForTemp: string;
@ -25,6 +26,7 @@ export interface KubeconfigManagerDependencies {
pathExists: PathExists; pathExists: PathExists;
removePath: RemovePath; removePath: RemovePath;
writeFile: WriteFile; writeFile: WriteFile;
certificate: SelfSignedCert;
} }
export class KubeconfigManager { export class KubeconfigManager {
@ -86,7 +88,7 @@ export class KubeconfigManager {
} }
get resolveProxyUrl() { get resolveProxyUrl() {
return `http://127.0.0.1:${this.dependencies.lensProxyPort.get()}/${this.cluster.id}`; return `https://127.0.0.1:${this.dependencies.lensProxyPort.get()}/${this.cluster.id}`;
} }
/** /**
@ -101,16 +103,19 @@ export class KubeconfigManager {
`kubeconfig-${id}`, `kubeconfig-${id}`,
); );
const kubeConfig = await cluster.getKubeconfig(); const kubeConfig = await cluster.getKubeconfig();
const { certificate } = this.dependencies;
const proxyConfig: PartialDeep<KubeConfig> = { const proxyConfig: PartialDeep<KubeConfig> = {
currentContext: contextName, currentContext: contextName,
clusters: [ clusters: [
{ {
name: contextName, name: contextName,
server: this.resolveProxyUrl, server: this.resolveProxyUrl,
skipTLSVerify: false,
caData: Buffer.from(certificate.cert).toString("base64"),
}, },
], ],
users: [ users: [
{ name: "proxy" }, { name: "proxy", username: "lens", password: "fake" },
], ],
contexts: [ contexts: [
{ {

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 type { SelfSignedCert } from "selfsigned";
import { lensProxyCertificateChannel } from "../../common/certificate/lens-proxy-certificate-channel";
import { getRequestChannelListenerInjectable } from "../utils/channel/channel-listeners/listener-tokens";
import lensProxyCertificateInjectable from "../../common/certificate/lens-proxy-certificate.injectable";
const lensProxyCertificateRequestHandlerInjectable = getRequestChannelListenerInjectable({
channel: lensProxyCertificateChannel,
handler: (di) => {
const lensProxyCertificate = di.inject(lensProxyCertificateInjectable).get() as SelfSignedCert;
return () => ({
cert: lensProxyCertificate.cert,
public: lensProxyCertificate.public,
private: "",
});
},
});
export default lensProxyCertificateRequestHandlerInjectable;

View File

@ -13,6 +13,7 @@ import lensProxyPortInjectable from "./lens-proxy-port.injectable";
import contentSecurityPolicyInjectable from "../../common/vars/content-security-policy.injectable"; import contentSecurityPolicyInjectable from "../../common/vars/content-security-policy.injectable";
import emitAppEventInjectable from "../../common/app-event-bus/emit-event.injectable"; import emitAppEventInjectable from "../../common/app-event-bus/emit-event.injectable";
import loggerInjectable from "../../common/logger.injectable"; import loggerInjectable from "../../common/logger.injectable";
import lensProxyCertificateInjectable from "../../common/certificate/lens-proxy-certificate.injectable";
const lensProxyInjectable = getInjectable({ const lensProxyInjectable = getInjectable({
id: "lens-proxy", id: "lens-proxy",
@ -27,6 +28,7 @@ const lensProxyInjectable = getInjectable({
contentSecurityPolicy: di.inject(contentSecurityPolicyInjectable), contentSecurityPolicy: di.inject(contentSecurityPolicyInjectable),
emitAppEvent: di.inject(emitAppEventInjectable), emitAppEvent: di.inject(emitAppEventInjectable),
logger: di.inject(loggerInjectable), logger: di.inject(loggerInjectable),
certificate: di.inject(lensProxyCertificateInjectable).get(),
}), }),
}); });

View File

@ -4,7 +4,8 @@
*/ */
import net from "net"; import net from "net";
import http from "http"; import https from "https";
import type http from "http";
import type httpProxy from "http-proxy"; import type httpProxy from "http-proxy";
import { apiPrefix, apiKubePrefix } from "../../common/vars"; import { apiPrefix, apiKubePrefix } from "../../common/vars";
import type { Router } from "../router/router"; import type { Router } from "../router/router";
@ -16,6 +17,7 @@ import assert from "assert";
import type { SetRequired } from "type-fest"; import type { SetRequired } from "type-fest";
import type { EmitAppEvent } from "../../common/app-event-bus/emit-event.injectable"; import type { EmitAppEvent } from "../../common/app-event-bus/emit-event.injectable";
import type { Logger } from "../../common/logger"; import type { Logger } from "../../common/logger";
import type { SelfSignedCert } from "selfsigned";
type GetClusterForRequest = (req: http.IncomingMessage) => Cluster | undefined; type GetClusterForRequest = (req: http.IncomingMessage) => Cluster | undefined;
@ -31,6 +33,7 @@ interface Dependencies {
readonly lensProxyPort: { set: (portNumber: number) => void }; readonly lensProxyPort: { set: (portNumber: number) => void };
readonly contentSecurityPolicy: string; readonly contentSecurityPolicy: string;
readonly logger: Logger; readonly logger: Logger;
readonly certificate: SelfSignedCert;
} }
const watchParam = "watch"; const watchParam = "watch";
@ -61,27 +64,33 @@ const disallowedPorts = new Set([
]); ]);
export class LensProxy { export class LensProxy {
protected proxyServer: http.Server; protected proxyServer: https.Server;
protected closed = false; protected closed = false;
protected retryCounters = new Map<string, number>(); protected retryCounters = new Map<string, number>();
constructor(private readonly dependencies: Dependencies) { constructor(private readonly dependencies: Dependencies) {
this.configureProxy(dependencies.proxy); this.configureProxy(dependencies.proxy);
this.proxyServer = http.createServer((req, res) => { this.proxyServer = https.createServer(
this.handleRequest(req as ServerIncomingMessage, res); {
}); key: dependencies.certificate.private,
cert: dependencies.certificate.cert,
},
(req, res) => {
this.handleRequest(req as ServerIncomingMessage, res);
},
);
this.proxyServer this.proxyServer
.on("upgrade", (req: ServerIncomingMessage, socket: net.Socket, head: Buffer) => { .on("upgrade", (req: ServerIncomingMessage, socket: net.Socket, head: Buffer) => {
const cluster = dependencies.getClusterForRequest(req); const cluster = this.dependencies.getClusterForRequest(req);
if (!cluster) { if (!cluster) {
this.dependencies.logger.error(`[LENS-PROXY]: Could not find cluster for upgrade request from url=${req.url}`); this.dependencies.logger.error(`[LENS-PROXY]: Could not find cluster for upgrade request from url=${req.url}`);
socket.destroy(); socket.destroy();
} else { } else {
const isInternal = req.url.startsWith(`${apiPrefix}?`); const isInternal = req.url.startsWith(`${apiPrefix}?`);
const reqHandler = isInternal ? dependencies.shellApiRequest : dependencies.kubeApiUpgradeRequest; const reqHandler = isInternal ? this.dependencies.shellApiRequest : this.dependencies.kubeApiUpgradeRequest;
(async () => reqHandler({ req, socket, head, cluster }))() (async () => reqHandler({ req, socket, head, cluster }))()
.catch(error => this.dependencies.logger.error("[LENS-PROXY]: failed to handle proxy upgrade", error)); .catch(error => this.dependencies.logger.error("[LENS-PROXY]: failed to handle proxy upgrade", error));
@ -157,6 +166,7 @@ export class LensProxy {
close() { close() {
this.dependencies.logger.info("[LENS-PROXY]: Closing server"); this.dependencies.logger.info("[LENS-PROXY]: Closing server");
this.proxyServer.close(); this.proxyServer.close();
this.closed = true; this.closed = true;
} }

View File

@ -17,8 +17,6 @@ const devStaticFileRouteHandlerInjectable = getInjectable({
return async ({ raw: { req, res }}: LensApiRequest<"/{path*}">): Promise<RouteResponse<Buffer>> => { return async ({ raw: { req, res }}: LensApiRequest<"/{path*}">): Promise<RouteResponse<Buffer>> => {
if (req.url === "/" || !req.url || !req.url.startsWith(publicPath)) { if (req.url === "/" || !req.url || !req.url.startsWith(publicPath)) {
req.url = `${publicPath}/index.html`; req.url = `${publicPath}/index.html`;
} else if (!req.url.startsWith("/build/")) {
return { statusCode: 404 };
} }
proxy.web(req, res, { target: proxyTarget }); proxy.web(req, res, { target: proxyTarget });

View File

@ -33,7 +33,7 @@ const createApplicationWindowInjectable = getInjectable({
defaultHeight: 900, defaultHeight: 900,
defaultWidth: 1440, defaultWidth: 1440,
getContentSource: () => ({ getContentSource: () => ({
url: `http://localhost:${lensProxyPort.get()}`, url: `https://lens.app:${lensProxyPort.get()}`,
}), }),
resizable: true, resizable: true,
windowFrameUtilitiesAreShown: isMac, windowFrameUtilitiesAreShown: isMac,

View File

@ -3,6 +3,7 @@
* 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 { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import { timingSafeEqual, X509Certificate } from "crypto";
import loggerInjectable from "../../../../common/logger.injectable"; import loggerInjectable from "../../../../common/logger.injectable";
import applicationWindowStateInjectable from "./application-window-state.injectable"; import applicationWindowStateInjectable from "./application-window-state.injectable";
import { BrowserWindow } from "electron"; import { BrowserWindow } from "electron";
@ -14,7 +15,7 @@ import lensResourcesDirInjectable from "../../../../common/vars/lens-resources-d
import isLinuxInjectable from "../../../../common/vars/is-linux.injectable"; import isLinuxInjectable from "../../../../common/vars/is-linux.injectable";
import applicationInformationToken from "../../../../common/vars/application-information-token"; import applicationInformationToken from "../../../../common/vars/application-information-token";
import pathExistsSyncInjectable from "../../../../common/fs/path-exists-sync.injectable"; import pathExistsSyncInjectable from "../../../../common/fs/path-exists-sync.injectable";
import lensProxyCertificateInjectable from "../../../../common/certificate/lens-proxy-certificate.injectable";
export type ElectronWindowTitleBarStyle = "hiddenInset" | "hidden" | "default" | "customButtonsOnHover"; export type ElectronWindowTitleBarStyle = "hiddenInset" | "hidden" | "default" | "customButtonsOnHover";
@ -56,6 +57,8 @@ const createElectronWindowInjectable = getInjectable({
const isLinux = di.inject(isLinuxInjectable); const isLinux = di.inject(isLinuxInjectable);
const applicationInformation = di.inject(applicationInformationToken); const applicationInformation = di.inject(applicationInformationToken);
const pathExistsSync = di.inject(pathExistsSyncInjectable); const pathExistsSync = di.inject(pathExistsSyncInjectable);
const lensProxyCertificate = di.inject(lensProxyCertificateInjectable).get();
const lensProxyX509Cert = new X509Certificate(lensProxyCertificate.cert);
return (configuration) => { return (configuration) => {
const applicationWindowState = di.inject( const applicationWindowState = di.inject(
@ -123,6 +126,13 @@ const createElectronWindowInjectable = getInjectable({
.webContents.on("dom-ready", () => { .webContents.on("dom-ready", () => {
configuration.onDomReady?.(); configuration.onDomReady?.();
}) })
.on("certificate-error", (event, url, error, certificate, shouldBeTrusted) => {
const cert = new X509Certificate(certificate.data);
const shouldTrustCert = cert.raw.length === lensProxyX509Cert.raw.length
&& timingSafeEqual(cert.raw, lensProxyX509Cert.raw);
shouldBeTrusted(shouldTrustCert);
})
.on("did-fail-load", (_event, code, desc) => { .on("did-fail-load", (_event, code, desc) => {
logger.error( logger.error(
`[CREATE-ELECTRON-WINDOW]: Failed to load window "${configuration.id}"`, `[CREATE-ELECTRON-WINDOW]: Failed to load window "${configuration.id}"`,

View File

@ -0,0 +1,32 @@
/**
* 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 electronAppInjectable from "../../electron-app/electron-app.injectable";
import { beforeElectronIsReadyInjectionToken } from "../runnable-tokens/before-electron-is-ready-injection-token";
const setupHostnamesInjectable = getInjectable({
id: "setup-hostnames",
instantiate: (di) => {
const app = di.inject(electronAppInjectable);
return {
id: "setup-hostnames",
run: () => {
app.commandLine.appendSwitch("host-rules", [
"MAP localhost 127.0.0.1",
"MAP lens.app 127.0.0.1",
"MAP *.lens.app 127.0.0.1",
].join());
return undefined;
},
};
},
injectionToken: beforeElectronIsReadyInjectionToken,
});
export default setupHostnamesInjectable;

View File

@ -0,0 +1,53 @@
/**
* 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 { generate } from "selfsigned";
import lensProxyCertificateInjectable from "../../../common/certificate/lens-proxy-certificate.injectable";
import { beforeElectronIsReadyInjectionToken } from "../runnable-tokens/before-electron-is-ready-injection-token";
const setupLensProxyCertificateInjectable = getInjectable({
id: "setup-lens-proxy-certificate",
instantiate: (di) => {
const lensProxyCertificate = di.inject(lensProxyCertificateInjectable);
return {
id: "setup-lens-proxy-certificate",
run: () => {
const cert = generate([
{ name: "commonName", value: "Lens Certificate Authority" },
{ name: "organizationName", value: "Lens" },
], {
keySize: 2048,
algorithm: "sha256",
days: 365,
extensions: [
{
name: "basicConstraints",
cA: true,
},
{
name: "subjectAltName",
altNames: [
{ type: 2, value: "*.lens.app" },
{ type: 2, value: "lens.app" },
{ type: 2, value: "localhost" },
{ type: 7, ip: "127.0.0.1" },
],
},
],
});
lensProxyCertificate.set(cert);
return undefined;
},
};
},
injectionToken: beforeElectronIsReadyInjectionToken,
});
export default setupLensProxyCertificateInjectable;

View File

@ -3,7 +3,6 @@
* 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 { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import { getAppVersionFromProxyServer } from "../../../common/utils";
import exitAppInjectable from "../../electron-app/features/exit-app.injectable"; import exitAppInjectable from "../../electron-app/features/exit-app.injectable";
import lensProxyInjectable from "../../lens-proxy/lens-proxy.injectable"; import lensProxyInjectable from "../../lens-proxy/lens-proxy.injectable";
import loggerInjectable from "../../../common/logger.injectable"; import loggerInjectable from "../../../common/logger.injectable";
@ -13,6 +12,9 @@ import showErrorPopupInjectable from "../../electron-app/features/show-error-pop
import { beforeApplicationIsLoadingInjectionToken } from "../runnable-tokens/before-application-is-loading-injection-token"; import { beforeApplicationIsLoadingInjectionToken } from "../runnable-tokens/before-application-is-loading-injection-token";
import buildVersionInjectable from "../../vars/build-version/build-version.injectable"; import buildVersionInjectable from "../../vars/build-version/build-version.injectable";
import initializeBuildVersionInjectable from "../../vars/build-version/init.injectable"; import initializeBuildVersionInjectable from "../../vars/build-version/init.injectable";
import lensProxyCertificateInjectable from "../../../common/certificate/lens-proxy-certificate.injectable";
import fetchInjectable from "../../../common/fetch/fetch.injectable";
import { Agent } from "https";
const setupLensProxyInjectable = getInjectable({ const setupLensProxyInjectable = getInjectable({
id: "setup-lens-proxy", id: "setup-lens-proxy",
@ -25,6 +27,8 @@ const setupLensProxyInjectable = getInjectable({
const isWindows = di.inject(isWindowsInjectable); const isWindows = di.inject(isWindowsInjectable);
const showErrorPopup = di.inject(showErrorPopupInjectable); const showErrorPopup = di.inject(showErrorPopupInjectable);
const buildVersion = di.inject(buildVersionInjectable); const buildVersion = di.inject(buildVersionInjectable);
const lensProxyCertificate = di.inject(lensProxyCertificateInjectable);
const fetch = di.inject(fetchInjectable);
return { return {
id: "setup-lens-proxy", id: "setup-lens-proxy",
@ -41,9 +45,13 @@ const setupLensProxyInjectable = getInjectable({
// test proxy connection // test proxy connection
try { try {
logger.info("🔎 Testing LensProxy connection ..."); logger.info("🔎 Testing LensProxy connection ...");
const versionFromProxy = await getAppVersionFromProxyServer( const versionResponse = await fetch(`https://127.0.0.1:${lensProxyPort.get()}/version`, {
lensProxyPort.get(), agent: new Agent({
); ca: lensProxyCertificate.get()?.cert,
}),
});
const { version: versionFromProxy } = await versionResponse.json() as { version: string };
if (buildVersion.get() !== versionFromProxy) { if (buildVersion.get() !== versionFromProxy) {
logger.error("Proxy server responded with invalid response"); logger.error("Proxy server responded with invalid response");

View File

@ -0,0 +1,24 @@
/**
* 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 { beforeFrameStartsFirstInjectionToken } from "../tokens";
import lensProxyCertificateInjectable from "../../../common/certificate/lens-proxy-certificate.injectable";
import requestLensProxyCertificateInjectable from "../../certificate/request-lens-proxy-certificate.injectable";
const setupLensProxyCertificateInjectable = getInjectable({
id: "setup-lens-proxy-certificate",
instantiate: (di) => ({
id: "setup-lens-proxy-certificate",
run: async () => {
const requestLensProxyCertificate = di.inject(requestLensProxyCertificateInjectable);
const lensProxyCertificate = di.inject(lensProxyCertificateInjectable);
lensProxyCertificate.set(await requestLensProxyCertificate());
},
}),
injectionToken: beforeFrameStartsFirstInjectionToken,
});
export default setupLensProxyCertificateInjectable;

View File

@ -0,0 +1,18 @@
/**
* 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 { lensProxyCertificateChannel } from "../../common/certificate/lens-proxy-certificate-channel";
import requestFromChannelInjectable from "../utils/channel/request-from-channel.injectable";
const requestLensProxyCertificateInjectable = getInjectable({
id: "request-lens-proxy-certificate",
instantiate: (di) => {
const requestFromChannel = di.inject(requestFromChannelInjectable);
return () => requestFromChannel(lensProxyCertificateChannel);
},
});
export default requestLensProxyCertificateInjectable;

View File

@ -88,8 +88,8 @@ describe("<NamespaceSelectFilter />", () => {
describe("once the subscribe resolves", () => { describe("once the subscribe resolves", () => {
beforeEach(async () => { beforeEach(async () => {
await fetchMock.resolveSpecific([ await fetchMock.resolveSpecific([
"http://127.0.0.1:12345/api-kube/api/v1/namespaces", "https://127.0.0.1:12345/api-kube/api/v1/namespaces",
], createMockResponseFromString("http://127.0.0.1:12345/api-kube/api/v1/namespaces", JSON.stringify({ ], createMockResponseFromString("https://127.0.0.1:12345/api-kube/api/v1/namespaces", JSON.stringify({
apiVersion: "v1", apiVersion: "v1",
kind: "NamespaceList", kind: "NamespaceList",
metadata: {}, metadata: {},

View File

@ -11,7 +11,7 @@ const apiBaseServerAddressInjectable = getInjectable({
instantiate: (di) => { instantiate: (di) => {
const { port } = di.inject(windowLocationInjectable); const { port } = di.inject(windowLocationInjectable);
return `http://127.0.0.1:${port}`; return `https://127.0.0.1:${port}`;
}, },
injectionToken: apiBaseServerAddressInjectionToken, injectionToken: apiBaseServerAddressInjectionToken,
}); });

View File

@ -11,18 +11,20 @@ import createKubeJsonApiInjectable from "../../common/k8s-api/create-kube-json-a
import isDevelopmentInjectable from "../../common/vars/is-development.injectable"; import isDevelopmentInjectable from "../../common/vars/is-development.injectable";
import showErrorNotificationInjectable from "../components/notifications/show-error-notification.injectable"; import showErrorNotificationInjectable from "../components/notifications/show-error-notification.injectable";
import windowLocationInjectable from "../../common/k8s-api/window-location.injectable"; import windowLocationInjectable from "../../common/k8s-api/window-location.injectable";
import { apiBaseServerAddressInjectionToken } from "../../common/k8s-api/api-base-configs";
const apiKubeInjectable = getInjectable({ const apiKubeInjectable = getInjectable({
id: "api-kube", id: "api-kube",
instantiate: (di) => { instantiate: (di) => {
assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "apiKube is only available in certain environments"); assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "apiKube is only available in certain environments");
const createKubeJsonApi = di.inject(createKubeJsonApiInjectable); const createKubeJsonApi = di.inject(createKubeJsonApiInjectable);
const apiBaseServerAddress = di.inject(apiBaseServerAddressInjectionToken);
const isDevelopment = di.inject(isDevelopmentInjectable); const isDevelopment = di.inject(isDevelopmentInjectable);
const showErrorNotification = di.inject(showErrorNotificationInjectable); const showErrorNotification = di.inject(showErrorNotificationInjectable);
const { port, host } = di.inject(windowLocationInjectable); const { host } = di.inject(windowLocationInjectable);
const apiKube = createKubeJsonApi({ const apiKube = createKubeJsonApi({
serverAddress: `http://127.0.0.1:${port}`, serverAddress: apiBaseServerAddress,
apiBase: apiKubePrefix, apiBase: apiKubePrefix,
debug: isDevelopment, debug: isDevelopment,
}, { }, {