mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Introduce initial authorization attempt for SHELLs and KUBECTL
Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
parent
2e6ced456d
commit
7708e662e1
@ -6,4 +6,4 @@
|
||||
/**
|
||||
* This is the header name that we use for request authentication
|
||||
*/
|
||||
export const lensAuthenticationHeader = "LENS-AUTHENTICATION";
|
||||
export const lensAuthenticationHeader = "Authorization";
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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<string> {
|
||||
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<KubeConfig> = {
|
||||
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: [
|
||||
{
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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<httpProxy.ServerOptions | undefined> {
|
||||
if (req.url?.startsWith(apiKubePrefix)) {
|
||||
delete req.headers.authorization;
|
||||
req.url = req.url.replace(apiKubePrefix, "");
|
||||
|
||||
return contextHandler.getApiTarget(isLongRunningRequest(req.url));
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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);
|
||||
};
|
||||
},
|
||||
|
||||
22
src/renderer/api/default-websocket-params.injectable.ts
Normal file
22
src/renderer/api/default-websocket-params.injectable.ts
Normal file
@ -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;
|
||||
@ -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<string, string | undefined> {
|
||||
export interface TerminalApiQuery extends Partial<Record<string, string>> {
|
||||
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<TerminalEvents> {
|
||||
@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<TerminalEvents> {
|
||||
}
|
||||
|
||||
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<TerminalEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
protected _onMessage({ data, ...evt }: MessageEvent<string>): 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:
|
||||
|
||||
@ -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<Defaulted<WebsocketApiParams, DefaultWebsocketApiParamNames>, DefaultWebsocketApiParamNames>;
|
||||
|
||||
export class WebSocketApi<Events extends WebSocketEvents> extends (EventEmitter as { new<T>(): TypedEventEmitter<T> })<Events> {
|
||||
protected socket: WebSocket | null = null;
|
||||
protected pendingCommands: string[] = [];
|
||||
protected readonly pendingCommands: string[] = [];
|
||||
protected reconnectTimer?: number;
|
||||
protected pingTimer?: number;
|
||||
protected params: Defaulted<WebsocketApiParams, keyof typeof WebSocketApi["defaultParams"]>;
|
||||
protected readonly params: Defaulted<WebsocketApiParams, "logging" | "reconnectDelay" | "flushOnOpen" | "pingMessage">;
|
||||
|
||||
@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<Events extends WebSocketEvents> 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<Events extends WebSocketEvents> 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<Events extends WebSocketEvents> 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<Events extends WebSocketEvents> extends (EventEmitter
|
||||
}
|
||||
|
||||
protected _onMessage({ data }: MessageEvent): void {
|
||||
this.emit("data", ...[data] as Arguments<Events["data"]>);
|
||||
this.emit("data", ...[data] as string[] as Arguments<Events["data"]>);
|
||||
this.writeLog("%cMESSAGE", "color:black;font-weight:bold;", data);
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user