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 authored and GitHub committed 2023-01-04 19:18:02 +02:00
1 parent fba93b889e
commit 7052dc0dba
35 files changed
+353 -66

No files matched your search

+9 -3
View File
@@ -102,7 +102,7 @@ describe("kubeconfig manager tests", () => {
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);
});
@@ -173,7 +173,10 @@ describe("kubeconfig manager tests", () => {
describe("when writing out new proxy kubeconfig resolves", () => {
beforeEach(async () => {
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", () => {
beforeEach(async () => {
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",
],
);
});
@@ -9,4 +9,11 @@ import electronAppInjectable from "./electron-app.injectable";
export default getGlobalOverride(electronAppInjectable, () => ({
getVersion: () => "6.0.0",
setLoginItemSettings: () => {},
commandLine: {
appendArgument: () => {},
appendSwitch: () => {},
getSwitchValue: () => "",
hasSwitch: () => false,
removeSwitch: () => {},
},
} as Partial<Electron.App> as Electron.App));
+4 -3
View File
@@ -4,10 +4,10 @@
*/
import type { RequestPromiseOptions } from "request-promise-native";
import request from "request-promise-native";
import { apiKubePrefix } from "../common/vars";
import type { Cluster } from "../common/cluster/cluster";
import { getInjectable } from "@ogre-tools/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>;
@@ -16,18 +16,19 @@ const k8sRequestInjectable = getInjectable({
instantiate: (di) => {
const lensProxyPort = di.inject(lensProxyPortInjectable);
const lensProxyCertificate = di.inject(lensProxyCertificateInjectable);
return async (
cluster: Cluster,
path: string,
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.json ??= true;
options.timeout ??= 30000;
options.headers.Host = `${cluster.id}.${new URL(kubeProxyUrl).host}`; // required in ClusterManager.getClusterForRequest()
return request(kubeProxyUrl + path, options);
};
@@ -11,7 +11,7 @@ const apiBaseServerAddressInjectable = getInjectable({
instantiate: (di) => {
const lensProxyPort = di.inject(lensProxyPortInjectable);
return `http://127.0.0.1:${lensProxyPort.get()}`;
return `https://127.0.0.1:${lensProxyPort.get()}`;
},
injectionToken: apiBaseServerAddressInjectionToken,
});
@@ -14,6 +14,7 @@ import getDirnameOfPathInjectable from "../../common/path/get-dirname.injectable
import pathExistsInjectable from "../../common/fs/path-exists.injectable";
import writeFileInjectable from "../../common/fs/write-file.injectable";
import removePathInjectable from "../../common/fs/remove.injectable";
import lensProxyCertificateInjectable from "../../common/certificate/lens-proxy-certificate.injectable";
export interface KubeConfigManagerInstantiationParameter {
cluster: Cluster;
@@ -34,6 +35,7 @@ const createKubeconfigManagerInjectable = getInjectable({
removePath: di.inject(removePathInjectable),
pathExists: di.inject(pathExistsInjectable),
writeFile: di.inject(writeFileInjectable),
certificate: di.inject(lensProxyCertificateInjectable).get(),
};
return (cluster) => new KubeconfigManager(dependencies, cluster);
@@ -15,6 +15,7 @@ import type { GetDirnameOfPath } from "../../common/path/get-dirname.injectable"
import type { PathExists } from "../../common/fs/path-exists.injectable";
import type { RemovePath } from "../../common/fs/remove.injectable";
import type { WriteFile } from "../../common/fs/write-file.injectable";
import type { SelfSignedCert } from "selfsigned";
export interface KubeconfigManagerDependencies {
readonly directoryForTemp: string;
@@ -25,6 +26,7 @@ export interface KubeconfigManagerDependencies {
pathExists: PathExists;
removePath: RemovePath;
writeFile: WriteFile;
certificate: SelfSignedCert;
}
export class KubeconfigManager {
@@ -86,7 +88,7 @@ export class KubeconfigManager {
}
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}`,
);
const kubeConfig = await cluster.getKubeconfig();
const { certificate } = this.dependencies;
const proxyConfig: PartialDeep<KubeConfig> = {
currentContext: contextName,
clusters: [
{
name: contextName,
server: this.resolveProxyUrl,
skipTLSVerify: false,
caData: Buffer.from(certificate.cert).toString("base64"),
},
],
users: [
{ name: "proxy" },
{ name: "proxy", username: "lens", password: "fake" },
],
contexts: [
{
@@ -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;
@@ -13,6 +13,7 @@ import lensProxyPortInjectable from "./lens-proxy-port.injectable";
import contentSecurityPolicyInjectable from "../../common/vars/content-security-policy.injectable";
import emitAppEventInjectable from "../../common/app-event-bus/emit-event.injectable";
import loggerInjectable from "../../common/logger.injectable";
import lensProxyCertificateInjectable from "../../common/certificate/lens-proxy-certificate.injectable";
const lensProxyInjectable = getInjectable({
id: "lens-proxy",
@@ -27,6 +28,7 @@ const lensProxyInjectable = getInjectable({
contentSecurityPolicy: di.inject(contentSecurityPolicyInjectable),
emitAppEvent: di.inject(emitAppEventInjectable),
logger: di.inject(loggerInjectable),
certificate: di.inject(lensProxyCertificateInjectable).get(),
}),
});
+17 -7
View File
@@ -4,7 +4,8 @@
*/
import net from "net";
import http from "http";
import https from "https";
import type http from "http";
import type httpProxy from "http-proxy";
import { apiPrefix, apiKubePrefix } from "../../common/vars";
import type { Router } from "../router/router";
@@ -16,6 +17,7 @@ import assert from "assert";
import type { SetRequired } from "type-fest";
import type { EmitAppEvent } from "../../common/app-event-bus/emit-event.injectable";
import type { Logger } from "../../common/logger";
import type { SelfSignedCert } from "selfsigned";
type GetClusterForRequest = (req: http.IncomingMessage) => Cluster | undefined;
@@ -31,6 +33,7 @@ interface Dependencies {
readonly lensProxyPort: { set: (portNumber: number) => void };
readonly contentSecurityPolicy: string;
readonly logger: Logger;
readonly certificate: SelfSignedCert;
}
const watchParam = "watch";
@@ -61,27 +64,33 @@ const disallowedPorts = new Set([
]);
export class LensProxy {
protected proxyServer: http.Server;
protected proxyServer: https.Server;
protected closed = false;
protected retryCounters = new Map<string, number>();
constructor(private readonly dependencies: Dependencies) {
this.configureProxy(dependencies.proxy);
this.proxyServer = http.createServer((req, res) => {
this.handleRequest(req as ServerIncomingMessage, res);
});
this.proxyServer = https.createServer(
{
key: dependencies.certificate.private,
cert: dependencies.certificate.cert,
},
(req, res) => {
this.handleRequest(req as ServerIncomingMessage, res);
},
);
this.proxyServer
.on("upgrade", (req: ServerIncomingMessage, socket: net.Socket, head: Buffer) => {
const cluster = dependencies.getClusterForRequest(req);
const cluster = this.dependencies.getClusterForRequest(req);
if (!cluster) {
this.dependencies.logger.error(`[LENS-PROXY]: Could not find cluster for upgrade request from url=${req.url}`);
socket.destroy();
} else {
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 }))()
.catch(error => this.dependencies.logger.error("[LENS-PROXY]: failed to handle proxy upgrade", error));
@@ -157,6 +166,7 @@ export class LensProxy {
close() {
this.dependencies.logger.info("[LENS-PROXY]: Closing server");
this.proxyServer.close();
this.closed = true;
}
@@ -17,8 +17,6 @@ const devStaticFileRouteHandlerInjectable = getInjectable({
return async ({ raw: { req, res }}: LensApiRequest<"/{path*}">): Promise<RouteResponse<Buffer>> => {
if (req.url === "/" || !req.url || !req.url.startsWith(publicPath)) {
req.url = `${publicPath}/index.html`;
} else if (!req.url.startsWith("/build/")) {
return { statusCode: 404 };
}
proxy.web(req, res, { target: proxyTarget });
@@ -33,7 +33,7 @@ const createApplicationWindowInjectable = getInjectable({
defaultHeight: 900,
defaultWidth: 1440,
getContentSource: () => ({
url: `http://localhost:${lensProxyPort.get()}`,
url: `https://lens.app:${lensProxyPort.get()}`,
}),
resizable: true,
windowFrameUtilitiesAreShown: isMac,
@@ -3,6 +3,7 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { timingSafeEqual, X509Certificate } from "crypto";
import loggerInjectable from "../../../../common/logger.injectable";
import applicationWindowStateInjectable from "./application-window-state.injectable";
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 applicationInformationToken from "../../../../common/vars/application-information-token";
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";
@@ -56,6 +57,8 @@ const createElectronWindowInjectable = getInjectable({
const isLinux = di.inject(isLinuxInjectable);
const applicationInformation = di.inject(applicationInformationToken);
const pathExistsSync = di.inject(pathExistsSyncInjectable);
const lensProxyCertificate = di.inject(lensProxyCertificateInjectable).get();
const lensProxyX509Cert = new X509Certificate(lensProxyCertificate.cert);
return (configuration) => {
const applicationWindowState = di.inject(
@@ -123,6 +126,13 @@ const createElectronWindowInjectable = getInjectable({
.webContents.on("dom-ready", () => {
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) => {
logger.error(
`[CREATE-ELECTRON-WINDOW]: Failed to load window "${configuration.id}"`,
@@ -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;
@@ -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;
@@ -3,7 +3,6 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { getAppVersionFromProxyServer } from "../../../common/utils";
import exitAppInjectable from "../../electron-app/features/exit-app.injectable";
import lensProxyInjectable from "../../lens-proxy/lens-proxy.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 buildVersionInjectable from "../../vars/build-version/build-version.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({
id: "setup-lens-proxy",
@@ -25,6 +27,8 @@ const setupLensProxyInjectable = getInjectable({
const isWindows = di.inject(isWindowsInjectable);
const showErrorPopup = di.inject(showErrorPopupInjectable);
const buildVersion = di.inject(buildVersionInjectable);
const lensProxyCertificate = di.inject(lensProxyCertificateInjectable);
const fetch = di.inject(fetchInjectable);
return {
id: "setup-lens-proxy",
@@ -41,9 +45,13 @@ const setupLensProxyInjectable = getInjectable({
// test proxy connection
try {
logger.info("🔎 Testing LensProxy connection ...");
const versionFromProxy = await getAppVersionFromProxyServer(
lensProxyPort.get(),
);
const versionResponse = await fetch(`https://127.0.0.1:${lensProxyPort.get()}/version`, {
agent: new Agent({
ca: lensProxyCertificate.get()?.cert,
}),
});
const { version: versionFromProxy } = await versionResponse.json() as { version: string };
if (buildVersion.get() !== versionFromProxy) {
logger.error("Proxy server responded with invalid response");