1
0
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:
Sebastian Malton 2022-12-06 15:16:59 -05:00
parent 2e6ced456d
commit 7708e662e1
12 changed files with 116 additions and 59 deletions

View File

@ -6,4 +6,4 @@
/**
* This is the header name that we use for request authentication
*/
export const lensAuthenticationHeader = "LENS-AUTHENTICATION";
export const lensAuthenticationHeader = "Authorization";

View File

@ -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);
});

View File

@ -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),

View File

@ -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: [
{

View File

@ -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,
});

View File

@ -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));

View File

@ -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);

View File

@ -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", () => {

View File

@ -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);
};
},

View 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;

View File

@ -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:

View File

@ -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);
}