diff --git a/src/common/vars/auth-header.ts b/src/common/vars/auth-header.ts index ba945a4c52..39dd825f8b 100644 --- a/src/common/vars/auth-header.ts +++ b/src/common/vars/auth-header.ts @@ -6,4 +6,4 @@ /** * This is the header name that we use for request authentication */ -export const lensAuthenticationHeader = "LENS-AUTHENTICATION"; +export const lensAuthenticationHeader = "Authorization"; diff --git a/src/main/__test__/kubeconfig-manager.test.ts b/src/main/__test__/kubeconfig-manager.test.ts index 43c8777b2e..4639c4a3e9 100644 --- a/src/main/__test__/kubeconfig-manager.test.ts +++ b/src/main/__test__/kubeconfig-manager.test.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getDiForUnitTesting } from "../getDiForUnitTesting"; -import { KubeconfigManager } from "../kubeconfig-manager/kubeconfig-manager"; +import type { KubeconfigManager } from "../kubeconfig-manager/kubeconfig-manager"; import type { Cluster } from "../../common/cluster/cluster"; import createKubeconfigManagerInjectable from "../kubeconfig-manager/create-kubeconfig-manager.injectable"; import { createClusterInjectionToken } from "../../common/cluster/create-cluster-injection-token"; @@ -30,6 +30,7 @@ import removePathInjectable from "../../common/fs/remove.injectable"; import pathExistsSyncInjectable from "../../common/fs/path-exists-sync.injectable"; import readJsonSyncInjectable from "../../common/fs/read-json-sync.injectable"; import writeJsonSyncInjectable from "../../common/fs/write-json-sync.injectable"; +import lensProxyPortInjectable from "../lens-proxy/lens-proxy-port.injectable"; const clusterServerUrl = "https://192.168.64.3:8443"; @@ -90,6 +91,8 @@ describe("kubeconfig manager tests", () => { ensureServer: ensureServerMock, })); + di.inject(lensProxyPortInjectable).set(9191); + const createCluster = di.inject(createClusterInjectionToken); createKubeconfigManager = di.inject(createKubeconfigManagerInjectable); @@ -102,8 +105,6 @@ describe("kubeconfig manager tests", () => { clusterServerUrl, }); - jest.spyOn(KubeconfigManager.prototype, "resolveProxyUrl", "get").mockReturnValue("http://127.0.0.1:9191/foo"); - kubeConfManager = createKubeconfigManager(clusterFake); }); diff --git a/src/main/kubeconfig-manager/create-kubeconfig-manager.injectable.ts b/src/main/kubeconfig-manager/create-kubeconfig-manager.injectable.ts index 6bca7cb086..48dea26da6 100644 --- a/src/main/kubeconfig-manager/create-kubeconfig-manager.injectable.ts +++ b/src/main/kubeconfig-manager/create-kubeconfig-manager.injectable.ts @@ -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 authHeaderValueInjectable from "../lens-proxy/auth-header-value.injectable"; export interface KubeConfigManagerInstantiationParameter { cluster: Cluster; @@ -29,6 +30,7 @@ const createKubeconfigManagerInjectable = getInjectable({ directoryForTemp: di.inject(directoryForTempInjectable), logger: di.inject(loggerInjectable), lensProxyPort: di.inject(lensProxyPortInjectable), + authHeaderValue: di.inject(authHeaderValueInjectable), joinPaths: di.inject(joinPathsInjectable), getDirnameOfPath: di.inject(getDirnameOfPathInjectable), removePath: di.inject(removePathInjectable), diff --git a/src/main/kubeconfig-manager/kubeconfig-manager.ts b/src/main/kubeconfig-manager/kubeconfig-manager.ts index f73baf1782..f867c2319b 100644 --- a/src/main/kubeconfig-manager/kubeconfig-manager.ts +++ b/src/main/kubeconfig-manager/kubeconfig-manager.ts @@ -15,11 +15,13 @@ 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 { lensAuthenticationHeader } from "../../common/vars/auth-header"; export interface KubeconfigManagerDependencies { readonly directoryForTemp: string; readonly logger: Logger; readonly lensProxyPort: { get: () => number }; + readonly authHeaderValue: string; joinPaths: JoinPaths; getDirnameOfPath: GetDirnameOfPath; pathExists: PathExists; @@ -47,7 +49,7 @@ export class KubeconfigManager { * @returns The path to the temporary kubeconfig */ async getPath(): Promise { - if (this.tempFilePath === null || !(await this.dependencies.pathExists(this.tempFilePath))) { + if (this.tempFilePath === null) { return await this.ensureFile(); } @@ -85,10 +87,6 @@ export class KubeconfigManager { } } - get resolveProxyUrl() { - return `http://127.0.0.1:${this.dependencies.lensProxyPort.get()}/${this.cluster.id}`; - } - /** * Creates new "temporary" kubeconfig that point to the kubectl-proxy. * This way any user of the config does not need to know anything about the auth etc. details. @@ -101,16 +99,23 @@ export class KubeconfigManager { `kubeconfig-${id}`, ); const kubeConfig = await cluster.getKubeconfig(); + const searchParams = new URLSearchParams({ + [lensAuthenticationHeader]: this.dependencies.authHeaderValue, + }); + const proxyConfig: PartialDeep = { currentContext: contextName, clusters: [ { name: contextName, - server: this.resolveProxyUrl, + server: `http://127.0.0.1:${this.dependencies.lensProxyPort.get()}/${this.cluster.id}?${searchParams}`, }, ], users: [ - { name: "proxy" }, + { + name: "proxy", + token: this.dependencies.authHeaderValue, + }, ], contexts: [ { diff --git a/src/main/lens-proxy/auth-header-value.injectable.ts b/src/main/lens-proxy/auth-header-value.injectable.ts index 5ccffdbd83..4c89d061c4 100644 --- a/src/main/lens-proxy/auth-header-value.injectable.ts +++ b/src/main/lens-proxy/auth-header-value.injectable.ts @@ -8,7 +8,7 @@ import { lensAuthenticationHeaderValueInjectionToken } from "../../common/auth/h const authHeaderValueInjectable = getInjectable({ id: "auth-header-value", - instantiate: () => uuid.v4(), + instantiate: () => `Bearer ${uuid.v4()}`, injectionToken: lensAuthenticationHeaderValueInjectionToken, }); diff --git a/src/main/lens-proxy/lens-proxy.ts b/src/main/lens-proxy/lens-proxy.ts index 6f38b67bd8..1f8af13fcb 100644 --- a/src/main/lens-proxy/lens-proxy.ts +++ b/src/main/lens-proxy/lens-proxy.ts @@ -19,6 +19,7 @@ import type { RouteRequest } from "../router/route-request.injectable"; import { lensAuthenticationHeader } from "../../common/vars/auth-header"; import { contentTypes } from "../router/router-content-types"; import { writeServerResponseFor } from "../router/write-server-response"; +import { URL } from "url"; type GetClusterForRequest = (req: http.IncomingMessage) => Cluster | undefined; @@ -78,10 +79,10 @@ export class LensProxy { this.proxyServer .on("upgrade", (req: ServerIncomingMessage, socket: net.Socket, head: Buffer) => { - const cluster = dependencies.getClusterForRequest(req); - const authHeader = req.headers[lensAuthenticationHeader.toLowerCase()]; + const cluster = this.dependencies.getClusterForRequest(req); + const url = new URL(req.url, "http://localhost"); - if (authHeader !== this.dependencies.authHeaderValue) { + if (url.searchParams.get(lensAuthenticationHeader) !== this.dependencies.authHeaderValue) { this.dependencies.logger.warn(`[LENS-PROXY]: Request from url=${req.url} missing authentication`); socket.destroy(); @@ -92,11 +93,21 @@ export class LensProxy { 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; + void (async () => { + try { + if (url.pathname === apiPrefix) { + await dependencies.shellApiRequest({ req, socket, head, cluster }); + } else if (url.pathname.startsWith(`${apiKubePrefix}/`)) { + req.url = req.url.slice(apiKubePrefix.length); - (async () => reqHandler({ req, socket, head, cluster }))() - .catch(error => this.dependencies.logger.error("[LENS-PROXY]: failed to handle proxy upgrade", error)); + await dependencies.kubeApiUpgradeRequest({ req, socket, head, cluster }); + } else { + this.dependencies.logger.warn(`[LENS-PROXY]: unknown upgrade request, url=${req.url}`); + } + } catch (error) { + this.dependencies.logger.error("[LENS-PROXY]: failed to handle proxy upgrade", error); + } + })(); } }); } @@ -224,7 +235,6 @@ export class LensProxy { protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ClusterContextHandler): Promise { if (req.url?.startsWith(apiKubePrefix)) { - delete req.headers.authorization; req.url = req.url.replace(apiKubePrefix, ""); return contextHandler.getApiTarget(isLongRunningRequest(req.url)); diff --git a/src/main/lens-proxy/proxy-functions/kube-api-upgrade-request.ts b/src/main/lens-proxy/proxy-functions/kube-api-upgrade-request.ts index ddd8e66261..4484716132 100644 --- a/src/main/lens-proxy/proxy-functions/kube-api-upgrade-request.ts +++ b/src/main/lens-proxy/proxy-functions/kube-api-upgrade-request.ts @@ -7,13 +7,12 @@ import { chunk } from "lodash"; import type { ConnectionOptions } from "tls"; import { connect } from "tls"; import url from "url"; -import { apiKubePrefix } from "../../../common/vars"; import type { ProxyApiRequestArgs } from "./types"; const skipRawHeaders = new Set(["Host", "Authorization"]); export async function kubeApiUpgradeRequest({ req, socket, head, cluster }: ProxyApiRequestArgs) { - const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, ""); + const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url; const proxyCa = cluster.contextHandler.resolveAuthProxyCa(); const apiUrl = url.parse(cluster.apiUrl); const pUrl = url.parse(proxyUrl); diff --git a/src/renderer/api/__tests__/websocket-api.test.ts b/src/renderer/api/__tests__/websocket-api.test.ts index 5dda62b60c..e2664a9174 100644 --- a/src/renderer/api/__tests__/websocket-api.test.ts +++ b/src/renderer/api/__tests__/websocket-api.test.ts @@ -16,7 +16,15 @@ describe("WebsocketApi tests", () => { let api: TestWebSocketApi; beforeEach(() => { - api = new TestWebSocketApi({}); + api = new TestWebSocketApi({ + authHeaderValue: "some-value", + defaultParams: { + flushOnOpen: true, + logging: false, + pingMessage: "{}", + reconnectDelay: 10, + }, + }, {}); }); describe("before connection", () => { diff --git a/src/renderer/api/create-terminal-api.injectable.ts b/src/renderer/api/create-terminal-api.injectable.ts index 255fa396f3..257cb72a3f 100644 --- a/src/renderer/api/create-terminal-api.injectable.ts +++ b/src/renderer/api/create-terminal-api.injectable.ts @@ -4,7 +4,9 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import assert from "assert"; +import authHeaderValueInjectable from "../auth/auth-header.injectable"; import hostedClusterIdInjectable from "../cluster-frame-context/hosted-cluster-id.injectable"; +import defaultWebsocketApiParamsInjectable from "./default-websocket-params.injectable"; import type { TerminalApiQuery } from "./terminal-api"; import { TerminalApi } from "./terminal-api"; @@ -14,12 +16,16 @@ const createTerminalApiInjectable = getInjectable({ id: "create-terminal-api", instantiate: (di): CreateTerminalApi => { const hostedClusterId = di.inject(hostedClusterIdInjectable); + const authHeaderValue = di.inject(authHeaderValueInjectable); + const defaultParams = di.inject(defaultWebsocketApiParamsInjectable); return (query) => { assert(hostedClusterId, "Can only create terminal APIs within a cluster frame"); return new TerminalApi({ hostedClusterId, + authHeaderValue, + defaultParams, }, query); }; }, diff --git a/src/renderer/api/default-websocket-params.injectable.ts b/src/renderer/api/default-websocket-params.injectable.ts new file mode 100644 index 0000000000..8664ebccc4 --- /dev/null +++ b/src/renderer/api/default-websocket-params.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 type { TerminalMessage } from "../../common/terminal/channels"; +import { TerminalChannels } from "../../common/terminal/channels"; +import isDevelopmentInjectable from "../../common/vars/is-development.injectable"; +import type { DefaultWebsocketApiParams } from "./websocket-api"; + + +const defaultWebsocketApiParamsInjectable = getInjectable({ + id: "default-websocket-api-params", + instantiate: (di): DefaultWebsocketApiParams => ({ + logging: di.inject(isDevelopmentInjectable), + reconnectDelay: 10, + flushOnOpen: true, + pingMessage: JSON.stringify({ type: TerminalChannels.PING } as TerminalMessage), + }), +}); + +export default defaultWebsocketApiParamsInjectable; diff --git a/src/renderer/api/terminal-api.ts b/src/renderer/api/terminal-api.ts index 75d907616c..90417cfe56 100644 --- a/src/renderer/api/terminal-api.ts +++ b/src/renderer/api/terminal-api.ts @@ -3,15 +3,16 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { WebSocketEvents } from "./websocket-api"; +import type { WebSocketApiDependencies, WebSocketEvents } from "./websocket-api"; import { WebSocketApi } from "./websocket-api"; import isEqual from "lodash/isEqual"; -import url from "url"; +import { URLSearchParams } from "url"; import { makeObservable, observable } from "mobx"; import { ipcRenderer } from "electron"; import logger from "../../common/logger"; import { once } from "lodash"; import { type TerminalMessage, TerminalChannels } from "../../common/terminal/channels"; +import { object } from "../utils"; enum TerminalColor { RED = "\u001b[31m", @@ -25,7 +26,7 @@ enum TerminalColor { NO_COLOR = "\u001b[0m", } -export interface TerminalApiQuery extends Record { +export interface TerminalApiQuery extends Partial> { id: string; node?: string; type?: string; @@ -36,7 +37,7 @@ export interface TerminalEvents extends WebSocketEvents { connected: () => void; } -export interface TerminalApiDependencies { +export interface TerminalApiDependencies extends WebSocketApiDependencies { readonly hostedClusterId: string; } @@ -46,7 +47,7 @@ export class TerminalApi extends WebSocketApi { @observable public isReady = false; constructor(protected readonly dependencies: TerminalApiDependencies, protected readonly query: TerminalApiQuery) { - super({ + super(dependencies, { flushOnOpen: false, pingInterval: 30, }); @@ -73,17 +74,12 @@ export class TerminalApi extends WebSocketApi { } const { hostname, protocol, port } = location; - const socketUrl = url.format({ - protocol: protocol.includes("https") ? "wss" : "ws", - hostname, - port, - pathname: "/api", - query: { - ...this.query, - shellToken: Buffer.from(authTokenArray).toString("base64"), - }, - slashes: true, - }); + const wsProtocol = protocol.includes("https") ? "wss" : "ws"; + const searchParams = new URLSearchParams([ + ...object.entries(this.query), + ["shellToken", Buffer.from(authTokenArray).toString("base64")], + ]); + const socketUrl = `${wsProtocol}://${hostname}:${port}/api?${searchParams}`; const onReady = once((data?: string) => { this.isReady = true; @@ -128,9 +124,9 @@ export class TerminalApi extends WebSocketApi { } } - protected _onMessage({ data, ...evt }: MessageEvent): void { + protected _onMessage({ data, ...evt }: MessageEvent): void { try { - const message = JSON.parse(data) as TerminalMessage; + const message = JSON.parse(data as string) as TerminalMessage; switch (message.type) { case TerminalChannels.STDOUT: diff --git a/src/renderer/api/websocket-api.ts b/src/renderer/api/websocket-api.ts index 74c0e5067a..b29629111f 100644 --- a/src/renderer/api/websocket-api.ts +++ b/src/renderer/api/websocket-api.ts @@ -7,11 +7,10 @@ import { observable, makeObservable } from "mobx"; import EventEmitter from "events"; import type TypedEventEmitter from "typed-emitter"; import type { Arguments } from "typed-emitter"; -import { isDevelopment } from "../../common/vars"; import type { Defaulted } from "../utils"; -import { TerminalChannels, type TerminalMessage } from "../../common/terminal/channels"; +import { lensAuthenticationHeader } from "../../common/vars/auth-header"; -interface WebsocketApiParams { +export interface WebsocketApiParams { /** * Flush pending commands on open socket * @@ -64,28 +63,32 @@ export interface WebSocketEvents { close: () => void; } +export interface WebSocketApiDependencies { + readonly authHeaderValue: string; + readonly defaultParams: DefaultWebsocketApiParams; +} + +export type DefaultWebsocketApiParamNames = "logging" | "reconnectDelay" | "flushOnOpen" | "pingMessage"; +export type DefaultWebsocketApiParams = Pick, DefaultWebsocketApiParamNames>; + export class WebSocketApi extends (EventEmitter as { new(): TypedEventEmitter }) { protected socket: WebSocket | null = null; - protected pendingCommands: string[] = []; + protected readonly pendingCommands: string[] = []; protected reconnectTimer?: number; protected pingTimer?: number; - protected params: Defaulted; + protected readonly params: Defaulted; @observable readyState = WebSocketApiState.PENDING; - private static readonly defaultParams = { - logging: isDevelopment, - reconnectDelay: 10, - flushOnOpen: true, - pingMessage: JSON.stringify({ type: TerminalChannels.PING } as TerminalMessage), - }; - - constructor(params: WebsocketApiParams) { + constructor( + protected readonly dependencies: WebSocketApiDependencies, + rawParams: WebsocketApiParams, + ) { super(); makeObservable(this); this.params = { - ...WebSocketApi.defaultParams, - ...params, + ...this.dependencies.defaultParams, + ...rawParams, }; const { pingInterval } = this.params; @@ -102,6 +105,11 @@ export class WebSocketApi extends (EventEmitter // close previous connection first this.socket?.close(); + const authParam = new URLSearchParams({ [lensAuthenticationHeader]: this.dependencies.authHeaderValue }); + const addingParam = url.includes("?") ? "&" : "?"; + + url += `${addingParam}${authParam}`; + // start new connection this.socket = new WebSocket(url); this.socket.addEventListener("open", ev => this._onOpen(ev)); @@ -129,7 +137,7 @@ export class WebSocketApi extends (EventEmitter if (!this.socket) return; this.socket.close(); this.socket = null; - this.pendingCommands = []; + this.pendingCommands.length = 0; this.clearAllListeners(); clearTimeout(this.reconnectTimer); clearInterval(this.pingTimer); @@ -153,7 +161,7 @@ export class WebSocketApi extends (EventEmitter protected flush() { const commands = this.pendingCommands; - this.pendingCommands = []; + this.pendingCommands.length = 0; for (const command of commands) { this.send(command); @@ -168,7 +176,7 @@ export class WebSocketApi extends (EventEmitter } protected _onMessage({ data }: MessageEvent): void { - this.emit("data", ...[data] as Arguments); + this.emit("data", ...[data] as string[] as Arguments); this.writeLog("%cMESSAGE", "color:black;font-weight:bold;", data); }