diff --git a/Makefile b/Makefile index 10184e370f..4a8348c419 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ endif .PHONY: dev build test clean -dev: app-deps dashboard-deps build-dashboard-server +dev: app-deps dashboard-deps yarn dev test: test-app test-dashboard @@ -39,25 +39,8 @@ clean-dashboard: test-dashboard: dashboard-deps cd dashboard && yarn test -build-dashboard: build-dashboard-server build-dashboard-client - -build-dashboard-server: dashboard-deps clean-dashboard - cd dashboard && yarn build-server -ifeq "$(DETECTED_OS)" "Linux" - rm binaries/server/linux/lens-server || true - cd dashboard && yarn pkg-server-linux -endif -ifeq "$(DETECTED_OS)" "Darwin" - rm binaries/server/darwin/lens-server || true - cd dashboard && yarn pkg-server-macos -endif -ifeq "$(DETECTED_OS)" "Windows" - rm binaries/server/windows/*.exe || true - cd dashboard && yarn pkg-server-win -endif - -build-dashboard-client: dashboard-deps clean-dashboard - cd dashboard && yarn build-client +build-dashboard: dashboard-deps clean-dashboard + cd dashboard && yarn build clean: rm -rf dist/* diff --git a/dashboard/client/components/app.tsx b/dashboard/client/components/app.tsx index 882bcdfcab..b07d72749c 100755 --- a/dashboard/client/components/app.tsx +++ b/dashboard/client/components/app.tsx @@ -14,6 +14,7 @@ import { UserManagement } from "./+user-management/user-management"; import { ConfirmDialog } from "./confirm-dialog"; import { usersManagementRoute } from "./+user-management/user-management.routes"; import { clusterRoute, clusterURL } from "./+cluster"; +import { KubeConfigDialog } from "./kubeconfig-dialog/kubeconfig-dialog"; import { Nodes, nodesRoute } from "./+nodes"; import { Workloads, workloadsRoute, workloadsURL } from "./+workloads"; import { Namespaces, namespacesRoute } from "./+namespaces"; @@ -70,6 +71,7 @@ class App extends React.Component { + diff --git a/dashboard/package.json b/dashboard/package.json index 1fa5b05984..3cc7863c34 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -1,31 +1,15 @@ { "name": "kontena-lens", - "version": "1.13.1", + "version": "0.0.0", "scripts": { - "app-dev": "yarn extract && webpack-cli --watch --cache --progress --output-path ../static/build/client/", - "build-client": "yarn extract && NODE_ENV=production webpack -p --cache --progress --output-path ../static/build/client/", - "build-server": "tsc --project server", - "pkg-server-linux": "pkg --no-bytecode --public --public-packages '*' -t node12-linux -o ../binaries/server/linux/lens-server build/server/app.js", - "pkg-server-macos": "pkg --no-bytecode --public --public-packages '*' -t node12-macos -o ../binaries/server/darwin/lens-server build/server/app.js", - "pkg-server-win": "pkg --no-bytecode --public --public-packages '*' -t node12-win -o ../binaries/server/win32/lens-server-x64.exe build/server/app.js", + "dev": "yarn extract && webpack-cli --watch --cache --progress --output-path ../static/build/client/", + "build": "yarn extract && NODE_ENV=production webpack -p --cache --progress --output-path ../static/build/client/", "test": "jest --config './test/jest.config.js'", "add-locale": "lingui add-locale", "extract": "lingui extract --clean", "compile": "lingui compile" }, - "nodemonConfig": { - "watch": [ - ".env", - "package.json", - "server" - ], - "ext": "ts", - "execMap": { - "ts": "ts-node --project server/tsconfig.json" - } - }, "dependencies": { - "@types/jsonpath": "^0.2.0", "axios": "^0.19.0", "chalk": "^2.4.2", "compression": "^1.7.4", @@ -33,9 +17,6 @@ "cors": "^2.8.5", "crypto-js": "^3.1.9-1", "dotenv": "^8.2.0", - "express": "^4.17.1", - "helmet": "^3.21.2", - "http-proxy-middleware": "^0.20.0", "ip": "^1.1.5", "js-yaml": "^3.13.1", "jsonpath": "^1.0.2", @@ -76,6 +57,7 @@ "@types/http-proxy-middleware": "^0.19.3", "@types/ip": "^1.1.0", "@types/jest": "^24.0.22", + "@types/jsonpath": "^0.2.0", "@types/js-yaml": "^3.12.1", "@types/lingui__macro": "^2.7.3", "@types/lodash": "^4.14.146", diff --git a/dashboard/server/api/get-cert-auth-data.ts b/dashboard/server/api/get-cert-auth-data.ts deleted file mode 100644 index 8c32b3ce17..0000000000 --- a/dashboard/server/api/get-cert-auth-data.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Get certificate auth data - -import * as fs from "fs"; -import * as util from "util"; -import config from "../config"; - -let caData: string = null - -export async function getCertificateAuthorityData(encoding = 'utf8'): Promise { - if (caData) { - return caData - } - if (!fs.existsSync(config.KUBERNETES_CA_CERT)) { - caData = config.KUBERNETES_CA_CERT - return caData - } - try { - const ca = await util.promisify(fs.readFile)(config.KUBERNETES_CA_CERT); - return Buffer.from(ca).toString(encoding); - } catch (error) { - return '' - } -} diff --git a/dashboard/server/api/get-cluster-info.ts b/dashboard/server/api/get-cluster-info.ts deleted file mode 100644 index c24178741a..0000000000 --- a/dashboard/server/api/get-cluster-info.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Get cluster info - -import config from "../config" -import { kubeRequest } from "./kube-request"; -import { IClusterInfo, IClusterConfigMap } from "../common/cluster" - -export async function getClusterInfo(): Promise { - const [configMap, kubeVersion, pharosVersion] = await Promise.all([ - getClusterConfigMap().catch(() => ({} as IClusterConfigMap)), - getKubeVersion().catch(() => null), - getPharosVersion().catch(() => null), - ]); - return { - ...configMap, - kubeVersion, - pharosVersion, - }; -} - -export async function getClusterConfigMap() { - const res = await kubeRequest<{ data: IClusterConfigMap }>({ - path: `/api/v1/namespaces/${config.LENS_NAMESPACE}/configmaps/config`, - }); - return res.data; -} - -export async function getKubeVersion() { - const res = await kubeRequest<{ gitVersion: string }>({ - path: "/version", - }); - return res.gitVersion.slice(1); -} - -export async function getPharosVersion() { - const res = await kubeRequest<{ data: { "pharos-version": string } }>({ - path: `/api/v1/namespaces/kube-system/configmaps/pharos-config`, - }); - return res ? res.data["pharos-version"] : null; -} diff --git a/dashboard/server/api/get-namespaces.ts b/dashboard/server/api/get-namespaces.ts deleted file mode 100644 index 4aad15a6b3..0000000000 --- a/dashboard/server/api/get-namespaces.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Get namespaces - -import config from "../config"; -import { KubeJsonApiDataList } from "../../client/api/kube-json-api"; -import { IKubeRequestParams, kubeRequest } from "./kube-request"; -import { reviewResourceAccess } from "./review-resource-access"; -import { getServiceAccountToken } from "./get-service-account-token" - -export async function getNamespaces(params: Partial = {}) { - return kubeRequest({ - ...params, - path: "/api/v1/namespaces", - }); -} - -export async function getAllowedNamespaces( - params: Partial, - fallbackNs = config.KUBERNETES_NAMESPACE, -): Promise { - try { - const allNamespaces = await getNamespaces(params); - const nsAccessStatuses = await Promise.all( - allNamespaces.items.map(ns => { - const { name } = ns.metadata; - return reviewResourceAccess(params, { - namespace: name, - resource: "pods", - verb: "list", - }); - }) - ); - return allNamespaces.items - .filter((ns, i) => nsAccessStatuses[i].allowed) - .map(ns => ns.metadata.name); - } catch (e) { - const serviceToken = await getServiceAccountToken(); - if (!serviceToken) { - return fallbackNs ? [fallbackNs] : []; - } - // fetch namespaces with service-account token (cluster-wide) - // and for every namespace make additional request to check if namespace available for user-token - const allNamespaces = await getNamespaces({ - authHeader: `Bearer ${serviceToken}` - }); - const nsAccessStatuses = await Promise.all( - allNamespaces.items.map(ns => { - const { name } = ns.metadata; - return reviewResourceAccess(params, { - namespace: name, - resource: "pods", - verb: "list", - }); - }) - ); - return allNamespaces.items - .filter((ns, i) => nsAccessStatuses[i].allowed) - .map(ns => ns.metadata.name); - } -} diff --git a/dashboard/server/api/get-service-account-token.ts b/dashboard/server/api/get-service-account-token.ts deleted file mode 100644 index 0728c26ab7..0000000000 --- a/dashboard/server/api/get-service-account-token.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Get service-account token - -import { existsSync, readFile } from "fs"; -import { promisify } from "util"; -import config from "../config" - -const tokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"; - -export async function getServiceAccountToken() { - const { SERVICE_ACCOUNT_TOKEN } = config; - - if (SERVICE_ACCOUNT_TOKEN) { - return SERVICE_ACCOUNT_TOKEN; - } - - if (existsSync(tokenPath)) { - const token = await promisify(readFile)(tokenPath); - return token.toString().trim(); - } - - return null; -} diff --git a/dashboard/server/api/is-cluster-admin.ts b/dashboard/server/api/is-cluster-admin.ts deleted file mode 100644 index 90c7d1b94e..0000000000 --- a/dashboard/server/api/is-cluster-admin.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Check cluster-admin rights for auth-token -// CLI: kubectl auth can-i '*' '*' --all-namespaces - -import { reviewResourceAccess } from "./review-resource-access"; -import { IKubeRequestParams } from "./kube-request"; - -export async function isClusterAdmin(params: Partial): Promise { - try { - const accessCheck = await reviewResourceAccess(params, { - resource: "*", - namespace: "*", - group: "*", - verb: "*", - }); - return accessCheck.allowed; - } catch (err) { - return false; - } -} diff --git a/dashboard/server/api/kube-request.ts b/dashboard/server/api/kube-request.ts deleted file mode 100644 index c62cef4a36..0000000000 --- a/dashboard/server/api/kube-request.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Kubernetes request api helper - -import config, { isSecure } from "../config"; -import axios, { AxiosError, AxiosRequestConfig } from "axios" -import * as https from "https"; -import { getCertificateAuthorityData } from "./get-cert-auth-data"; -import { logger, sanitizeHeaders } from "../utils/logger"; -import { getServiceAccountToken } from "./get-service-account-token"; - -export interface IKubeRequestParams extends AxiosRequestConfig { - path: string; - authHeader?: string; -} - -export async function kubeRequest(params: IKubeRequestParams): Promise { - const { KUBE_CLUSTER_URL, KUBERNETES_CLIENT_CERT, KUBERNETES_CLIENT_KEY } = config; - const serviceToken = await getServiceAccountToken(); - const defaultAuthHeader = serviceToken ? `Bearer ${serviceToken}` : ""; - const { - authHeader = defaultAuthHeader, - url = KUBE_CLUSTER_URL, - path = "", - ...reqConfig - } = params; - - // add access token - reqConfig.headers = Object.assign({}, reqConfig.headers, { - "Content-type": "application/json", - }); - - if (!KUBERNETES_CLIENT_CERT && authHeader) { - reqConfig.headers["Authorization"] = authHeader; - } - - // allow requests to kube-cluster without valid ssl certs.. - reqConfig.httpsAgent = new https.Agent({ - rejectUnauthorized: isSecure(), - cert: KUBERNETES_CLIENT_CERT, - key: KUBERNETES_CLIENT_KEY, - ca: await getCertificateAuthorityData(), - }); - - const reqUrl = url + path; - - return axios(reqUrl, reqConfig) - .then(res => res.data) - .catch((error: AxiosError) => { - const { message, config } = error; - logger.error(`[KUBE-REQUEST]: ${message}`, { - code: error.code, - method: config.method, - url: config.url, - headers: sanitizeHeaders(config.headers), - params: config.params, - }); - throw error; - }); -} diff --git a/dashboard/server/api/review-resource-access.ts b/dashboard/server/api/review-resource-access.ts deleted file mode 100644 index 6ec4e5536a..0000000000 --- a/dashboard/server/api/review-resource-access.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Get resource access review -// Docs: https://kubernetes.io/docs/reference/access-authn-authz/authorization/ -import { IKubeRequestParams, kubeRequest } from "./kube-request"; - -interface IResourceAccess { - apiVersion: string; - kind: string; - status: IResourceAccessStatus; -} - -export interface IResourceAccessStatus { - allowed: boolean; - denied?: boolean; - reason?: string; - evaluationError?: string; -} - -interface IResourceAccessAttributes { - group?: string | "*"; - resource?: string | "*"; - verb?: "get" | "list" | "create" | "update" | "patch" | "watch" | "proxy" | "redirect" | "delete" | "deletecollection" | "*"; - namespace?: string | "*"; -} - -export async function reviewResourceAccess( - params: Partial = {}, - attrs: IResourceAccessAttributes -): Promise { - try { - const accessReview = await kubeRequest({ - ...params, - method: "POST", - path: "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews", - data: { - spec: { - resourceAttributes: attrs - } - } - }); - return accessReview.status; - } catch (err) { - return { - allowed: false, - reason: err.toString(), - } - } -} diff --git a/dashboard/server/api/review-token.ts b/dashboard/server/api/review-token.ts deleted file mode 100644 index eb132e9acd..0000000000 --- a/dashboard/server/api/review-token.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Check validity of auth-token -import { kubeRequest } from "./kube-request"; - -export interface ITokenReview { - apiVersion: string; - kind: string; - status: ITokenReviewStatus; -} - -export interface ITokenReviewStatus { - authenticated: boolean; - user: { - username?: string; - uid?: string; - groups?: string[]; - }; - error?: string[]; -} - -export async function reviewToken(authToken: string): Promise { - try { - const tokenReview = await kubeRequest({ - path: "/apis/authentication.k8s.io/v1/tokenreviews", - method: "POST", - data: { - spec: { - token: authToken - } - } - }); - return tokenReview.status; - } catch (err) { - return { - authenticated: false, - user: {}, - error: [err.toString()], - } - } -} diff --git a/dashboard/server/app.ts b/dashboard/server/app.ts deleted file mode 100644 index 73da918e89..0000000000 --- a/dashboard/server/app.ts +++ /dev/null @@ -1,76 +0,0 @@ -import config, { BUILD_DIR, CLIENT_DIR } from "../server/config" - -import path from "path" -import fs from "fs" -import express from "express" -import cookieSession from "cookie-session" -import compression from "compression" -import helmet from "helmet" -import morgan from "morgan" -import { logger } from "../server/utils/logger" -import { configRoute, kubeconfigRoute, kubewatchRoute, metricsRoute, readyStateRoute } from "../server/routes"; -import { useRequestHeaderToken } from "../server/middlewares"; - -const { - IS_PRODUCTION, LOCAL_SERVER_PORT, API_PREFIX, - SESSION_NAME, SESSION_SECRET, -} = config; - -const app = express(); -const localApis = express.Router(); -const outputDir = path.resolve(process.cwd(), BUILD_DIR, CLIENT_DIR); - -app.set('trust proxy', 1); // trust first proxy - -localApis.use( - configRoute(), - readyStateRoute(), - kubeconfigRoute(), - kubewatchRoute(), - metricsRoute() -); - -// https://github.com/expressjs/cookie-session -app.use(cookieSession({ - name: SESSION_NAME, - secret: SESSION_SECRET, - secure: IS_PRODUCTION, - httpOnly: true, - maxAge: 365 * 24 * 60 * 60 * 1000, // 1 year -})); - -// protect from well-known web vulnerabilities by setting HTTP headers appropriately -// https://github.com/helmetjs/helmet -app.use(helmet({ - hsts: { - includeSubDomains: false, - } -})); - -// use auth-token from request headers (if applicable via proxy) -app.use(useRequestHeaderToken()); - -// requests logging -app.use(morgan('tiny')); - -// enable gzip compression -app.use(compression()); - -app.use(express.urlencoded({ extended: true })); // for parsing application/x-www-form-urlencoded -app.use("/", express.static(outputDir)); // handle static files (assets) - -app.use(API_PREFIX.BASE, express.json({ limit: "10mb" }), localApis); - -// handle all page requests via index.html, in development mode it's managed by webpack-dev-server -app.all('*', (req, res) => { - const indexHtml = path.resolve(outputDir, 'index.html'); - if (fs.existsSync(indexHtml)) res.sendFile(indexHtml); - else { - res.send("Error: build/index.html doesn't exists"); - } -}); - -// run server -const server = app.listen(LOCAL_SERVER_PORT, "127.0.0.1", () => { - logger.appStarted(LOCAL_SERVER_PORT, 'Server started'); -}); diff --git a/dashboard/server/common/cluster.ts b/dashboard/server/common/cluster.ts index 6ab519df14..fad6991626 100644 --- a/dashboard/server/common/cluster.ts +++ b/dashboard/server/common/cluster.ts @@ -1,9 +1,4 @@ -export interface IClusterConfigMap { - clusterName: string; - clusterUrl: string; -} - -export interface IClusterInfo extends IClusterConfigMap { +export interface IClusterInfo { kubeVersion?: string; - pharosVersion?: string; + clusterName?: string; } diff --git a/dashboard/server/middlewares/index.ts b/dashboard/server/middlewares/index.ts deleted file mode 100644 index a0204435e8..0000000000 --- a/dashboard/server/middlewares/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./kube-proxy" -export * from "./terminal-proxy" -export * from "./use-header-token" diff --git a/dashboard/server/middlewares/kube-proxy.ts b/dashboard/server/middlewares/kube-proxy.ts deleted file mode 100644 index 5313566a00..0000000000 --- a/dashboard/server/middlewares/kube-proxy.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Request } from "express"; -import proxy from "http-proxy-middleware" -import { userSession } from "../user-session"; -import config, { isSecure } from "../config"; - -export function kubeProxy(serviceUrl: string, proxyConfig: proxy.Config = {}) { - const { IS_PRODUCTION } = config; - return proxy({ - target: serviceUrl, - secure: isSecure(), // verify the ssl certs - logLevel: IS_PRODUCTION ? "info" : "debug", - changeOrigin: true, // needed for virtual hosted sites - pathRewrite: (path, req: Request) => { - return path.replace(req.baseUrl, ""); // remove client-prefix, e.g "/api-kube" - }, - onProxyReq(proxyReq, req: Request, res) { - const { authHeader } = userSession.get(req); - if (authHeader) { - proxyReq.setHeader("Authorization", authHeader); - } - }, - ...proxyConfig, - }) -} diff --git a/dashboard/server/middlewares/terminal-proxy.ts b/dashboard/server/middlewares/terminal-proxy.ts deleted file mode 100644 index a710cb2320..0000000000 --- a/dashboard/server/middlewares/terminal-proxy.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextFunction } from "express"; -import proxy from "http-proxy-middleware" -import appConfig from "../config" - -const { KUBE_TERMINAL_URL, API_PREFIX, IS_PRODUCTION } = appConfig; - -interface ITerminalProxy extends NextFunction { - upgrade: () => void; -} - -export const terminalProxy = proxy({ - target: KUBE_TERMINAL_URL, - ws: true, - changeOrigin: true, - logLevel: IS_PRODUCTION ? "info" : "debug", - pathRewrite: { - ["^" + API_PREFIX.TERMINAL]: "" // remove api-prefix - } -}) as ITerminalProxy; \ No newline at end of file diff --git a/dashboard/server/middlewares/use-header-token.ts b/dashboard/server/middlewares/use-header-token.ts deleted file mode 100644 index 83f6893503..0000000000 --- a/dashboard/server/middlewares/use-header-token.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Allow to use "Authorization" from request for auto-login (when provided by proxy) -import { NextFunction, Request, Response } from "express" -import { userSession } from "../user-session"; - -export function useRequestHeaderToken() { - return (req: Request, res: Response, next: NextFunction) => { - const authorization = req.headers["authorization"] || req.headers["x-lens-kubectl-token"]; - const { authHeader, isUserLogin } = userSession.get(req); - const userHasOwnToken = authHeader && isUserLogin; - - // don't overwrite user's login credentials - if (authorization && !userHasOwnToken && authHeader !== authorization) { - userSession.save(req, { - authHeader: authorization.toString(), - }); - } - - next(); - } -} diff --git a/dashboard/server/routes/config-route.ts b/dashboard/server/routes/config-route.ts deleted file mode 100644 index 25b8dccbc6..0000000000 --- a/dashboard/server/routes/config-route.ts +++ /dev/null @@ -1,48 +0,0 @@ -//-- Config route - -import config from "../config"; -import { IConfig } from "../common/config" -import { Router } from "express"; -import { userSession } from "../user-session"; -import { getClusterInfo } from "../api/get-cluster-info"; -import { isClusterAdmin } from "../api/is-cluster-admin"; -import { getAllowedNamespaces } from "../api/get-namespaces"; -import { parseJwt } from "../utils/parse-jwt"; - -export function configRoute() { - const router = Router(); - - router.route('/config') - .get(async (req, res) => { - const { username, authHeader } = userSession.get(req); - const authToken = userSession.getToken(req); - - const data: IConfig = { - clusterName: config.KUBE_CLUSTER_NAME, - lensVersion: config.LENS_VERSION, - lensTheme: config.LENS_THEME, - chartsEnabled: !!config.CHARTS_ENABLED, - kubectlAccess: !!req.headers["x-lens-kubectl-token"] - }; - - // load config data from other places - const loading: Promise[] = [ - getClusterInfo().then(info => Object.assign(data, info)), - ]; - - // validate user token from session and fetch more config data - if (authToken) { - const { sub, email } = parseJwt(authToken); - data.username = email || sub || username; - data.token = authToken; - loading.push( - isClusterAdmin({ authHeader }).then(isAdmin => data.isClusterAdmin = isAdmin), - getAllowedNamespaces({ authHeader }).then(list => data.allowedNamespaces = list), - ); - } - await Promise.all(loading); - res.json(data); - }); - - return router; -} diff --git a/dashboard/server/routes/index.ts b/dashboard/server/routes/index.ts deleted file mode 100644 index d7ca1a4cb1..0000000000 --- a/dashboard/server/routes/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./config-route" -export * from "./kubeconfig-route" -export * from "./kubewatch-route" -export * from "./metrics-route" -export * from "./ready-state-route" diff --git a/dashboard/server/routes/kubeconfig-route.ts b/dashboard/server/routes/kubeconfig-route.ts deleted file mode 100644 index 9ed0f445e6..0000000000 --- a/dashboard/server/routes/kubeconfig-route.ts +++ /dev/null @@ -1,100 +0,0 @@ -//-- Kubeconfig route - -import { Router } from "express"; -import { AxiosError } from "axios"; -import { userSession } from "../user-session"; -import { kubeRequest } from "../api/kube-request"; -import { Secret } from "../../client/api/endpoints"; -import { base64 } from "../../client/utils/base64"; -import { getCertificateAuthorityData } from "../api/get-cert-auth-data"; -import { getClusterConfigMap } from "../api/get-cluster-info"; - -interface IKubeConfigParams { - username: string; - authToken: string; - certificateAuthority: string; - namespace?: string; -} - -export function kubeconfigRoute() { - const router = Router(); - - router.route('/kubeconfig/user') - .get(async (req, res) => { - const { username = "kubernetes" } = userSession.get(req); - const authToken = userSession.getToken(req); - const cert = await getCertificateAuthorityData('base64'); - const data = await generateKubeConfig({ - username, - authToken: authToken, - certificateAuthority: cert - }); - res.json(data); - }); - - router.route('/kubeconfig/service-account/:namespace/:account') - .get(async (req, res) => { - const { authHeader } = userSession.get(req); - const { namespace, account } = req.params; - try { - const secret = await kubeRequest<{ items: Secret[] }>({ - path: `/api/v1/namespaces/${namespace}/secrets`, - authHeader: authHeader, - }).then(secrets => { - return secrets.items.find(secret => { - const { annotations } = secret.metadata; - return annotations && annotations["kubernetes.io/service-account.name"] == account; - }); - }); - const data = await generateKubeConfig({ - username: account, - namespace: namespace, - authToken: base64.decode(secret.data.token), - certificateAuthority: secret.data["ca.crt"], - }); - res.json(data); - } catch (err) { - const { response }: AxiosError = err; - res.status(403).json(response ? response.data : err.toString()); - } - }); - - return router; -} - -async function generateKubeConfig(params: IKubeConfigParams) { - const { clusterName, clusterUrl } = await getClusterConfigMap(); - const { authToken, username, certificateAuthority, namespace = "" } = params; - return { - 'apiVersion': 'v1', - 'kind': 'Config', - 'clusters': [ - { - 'name': clusterName, - 'cluster': { - 'server': clusterUrl, - 'certificate-authority-data': certificateAuthority - } - } - ], - 'users': [ - { - 'name': username, - 'user': { - 'token': authToken, - } - } - ], - 'contexts': [ - { - 'name': clusterName, - 'context': { - 'user': username, - 'cluster': clusterName, - 'namespace': namespace, - } - } - ], - 'current-context': clusterName - } -} diff --git a/dashboard/server/routes/kubewatch-route.ts b/dashboard/server/routes/kubewatch-route.ts deleted file mode 100644 index cd85fd0222..0000000000 --- a/dashboard/server/routes/kubewatch-route.ts +++ /dev/null @@ -1,139 +0,0 @@ -//-- Streaming k8s watch-api events - -import axios from "axios" -import { Router } from "express"; -import { IncomingMessage } from "http"; -import { kubeRequest } from "../api/kube-request"; -import { IKubeWatchEvent, IKubeWatchRouteEvent, IKubeWatchRouteQuery} from "../common/kubewatch" -import { userSession } from "../user-session"; -import { logger } from "../utils/logger"; - -export function kubewatchRoute() { - const router = Router(); - - router.route('/watch') - .get(async (req, res) => { - const { authHeader } = userSession.get(req); - const queryParams: IKubeWatchRouteQuery = req.query; - const apis: string[] = [].concat(queryParams.api || []); - const streams = new Map(); - const eventsBuffer = new Map(); - let isClosing = false; - - if (!apis.length) { - res.status(400).json({ - message: "Empty request. Query params 'api' are not provided.", - example: "?api=/api/v1/pods&api=/api/v1/nodes", - }); - return; - } - - res.header({ - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive' - }); - - // init streams - const cancelToken = axios.CancelToken.source(); - apis.forEach(apiUrl => { - console.log("[KUBE-WATCH] init stream", apiUrl); - const connecting = kubeRequest({ - path: apiUrl, - responseType: "stream", - authHeader: authHeader, - cancelToken: cancelToken.token, - }); - connecting.then(stream => { - streams.set(apiUrl, stream); // save connection for clean up - stream.socket.setKeepAlive(true); // keep connection alive - let lastUnusedBuffer = "" - return stream - .on("data", (buffer: Buffer) => { - const data = lastUnusedBuffer + buffer.toString().trim(); - data.split("\n").map(str => { - try { - const eventObj = JSON.parse(str); - bufferEvent(eventObj); // handle - lastUnusedBuffer = ""; // clean up since parsing was successful - } catch (err) { - lastUnusedBuffer = str; // invalid json, tail must wait next incoming data - } - }); - }) - .on("end", () => { - // client must update resource-version and try to reconnect - console.log(`[KUBE-WATCH] stream ended ${apiUrl}`) - sendEvent({ - type: "STREAM_END", - url: apiUrl, - status: stream.statusCode, - }) - }); - }, err => { - logger.error(`[KUBE-WATCH] error ${apiUrl}`, err); - sendEvent({ - type: "STREAM_END", - url: apiUrl, - status: 410, - }) - }); - }); - - function getEventBufferId(evt: IKubeWatchEvent) { - const { object, type } = evt; - const { kind } = object; - let { metadata: { uid } } = object; - if (kind === "Event") { - uid = (object as any).involvedObject.uid; // reason: uid for events always unique - } - return `${type}:${kind}-${uid}` - } - - function bufferEvent(evt: IKubeWatchEvent) { - const id = getEventBufferId(evt); - if (eventsBuffer.has(id)) { - eventsBuffer.delete(id); // clear to move event to the end in map's "timeline" - } - eventsBuffer.set(id, evt); // save latest event by object's identity - } - - function sendEvent(evt: IKubeWatchEvent | IKubeWatchRouteEvent, autoFlush = true) { - if (isClosing) return; - // convert to "text/event-stream" format - res.write(`data: ${JSON.stringify(evt)}\n\n`); - if (autoFlush) { - // @ts-ignore - res.flush(); - } - } - - // process sending events - const flushInterval = setInterval(() => { - const eventsPack = Array.from(eventsBuffer.entries()) - .slice(0, 100) // max limit per sending - .map(([id, evt]) => { - eventsBuffer.delete(id); // clean up used event - return evt; - }); - if (eventsPack.length > 0) { - eventsPack.forEach(evt => sendEvent(evt, false)); - // @ts-ignore - res.flush(); - } - }, 1000); - - function onClose() { - if (isClosing) return; - isClosing = true; - clearInterval(flushInterval); - streams.forEach(stream => stream.removeAllListeners("end")); - cancelToken.cancel(); - } - - req.on("close", onClose); - res.on("finish", onClose); - }); - - return router; -} diff --git a/dashboard/server/routes/metrics-route.ts b/dashboard/server/routes/metrics-route.ts deleted file mode 100644 index 8234432098..0000000000 --- a/dashboard/server/routes/metrics-route.ts +++ /dev/null @@ -1,82 +0,0 @@ -//-- Metrics -// https://prometheus.io/docs/prometheus/latest/querying/api/ - -import { Router } from "express"; -import config from "../config"; -import { kubeRequest } from "../api/kube-request"; -import { userSession } from "../user-session"; -import { AxiosError } from "axios"; -import { IMetrics } from "../../client/api/endpoints/metrics.api"; -import { IMetricsQuery } from "../common/metrics" - - -export function metricsRoute() { - const router = Router(); - - router.post("/metrics", async (req, res, next) => { - const { authHeader } = userSession.get(req); - const { namespace, ...queryParams } = req.query; - const query: IMetricsQuery = req.body; - - /*eslint-disable */ - // add default namespace for rbac-proxy validation - if (!queryParams.kubernetes_namespace) { - queryParams.kubernetes_namespace = config.STATS_NAMESPACE; - } - /*eslint-enble */ - - // prometheus metrics loader - const attempts: { [query: string]: number } = {}; - const maxAttempts = 5; - const loadMetrics = (query: string): Promise => { - const attempt = attempts[query] = (attempts[query] || 0) + 1; - return kubeRequest({ - url: config.KUBE_METRICS_URL, - path: "/api/v1/query_range", - authHeader: authHeader, - params: { - query: query, - ...queryParams, - }, - }).catch(async (err: AxiosError) => { - // https://github.com/axios/axios#handling-errors - if (!err.response && attempt < maxAttempts) { - await new Promise(resolve => setTimeout(resolve, attempt * 1000)); // add delay before repeating request - return loadMetrics(query); - } - return { - status: err.toString(), - data: { - result: [] - }, - } as IMetrics; - }) - }; - - // return data in same structure as query - let data: any; - try { - if (typeof query === "string") { - data = await loadMetrics(query) - } - else if (Array.isArray(query)) { - data = await Promise.all(query.map(loadMetrics)); - } - else { - data = {}; - const result = await Promise.all( - Object.values(query).map(loadMetrics) - ); - Object.keys(query).forEach((metricName, index) => { - data[metricName] = result[index]; - }); - } - - res.json(data); - } catch (err) { - next(err); - } - }); - - return router; -} diff --git a/dashboard/server/routes/ready-state-route.ts b/dashboard/server/routes/ready-state-route.ts deleted file mode 100644 index 93b4ba50e1..0000000000 --- a/dashboard/server/routes/ready-state-route.ts +++ /dev/null @@ -1,16 +0,0 @@ -//-- App readiness checker - -import { Router } from "express"; - -export function readyStateRoute() { - const router = Router(); - - router.route('/ready') - .get(async (req, res) => { - const serviceWaitingList: string[] = []; - - res.json(serviceWaitingList); - }); - - return router; -} diff --git a/dashboard/server/user-session.ts b/dashboard/server/user-session.ts deleted file mode 100644 index b4e989adb5..0000000000 --- a/dashboard/server/user-session.ts +++ /dev/null @@ -1,24 +0,0 @@ -//-- User sessions helper - -import { Request } from "express"; -import CookieSessionObject = CookieSessionInterfaces.CookieSessionObject; - -interface IUserSession extends CookieSessionObject { - authHeader: string; - username?: string; - isUserLogin?: boolean; // authorization via user's manual login with credentials -} - -export const userSession = { - get(req: Request): Partial { - return req.session; - }, - save(req: Request, data: Partial = {}) { - Object.assign(req.session, data); - }, - getToken(req: Request): string { - const { authHeader = "" } = this.get(req); - const [type, token = ""] = authHeader.split(" "); - return token; - } -}; diff --git a/dashboard/server/utils/kube-config.dev.ts b/dashboard/server/utils/kube-config.dev.ts deleted file mode 100644 index a3543f8124..0000000000 --- a/dashboard/server/utils/kube-config.dev.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Load & parse local kubernetes config (dev-only) - -import * as jsYaml from "js-yaml" -import * as fs from "fs" -import * as os from "os" -import chalk from "chalk"; -import { logger } from "./logger"; - -interface IKubeConfigParams { - clusterUrl: string; - userToken: string; -} - -export function getKubeConfigDev(): Partial { - const KUBE_CONFIG_FILE = process.env.KUBE_CONFIG_FILE; - if (!KUBE_CONFIG_FILE) { - return {} - } - let filePath = "" - try { - filePath = KUBE_CONFIG_FILE.replace("~", os.homedir()); - const yaml = fs.readFileSync(filePath).toString(); - const config = jsYaml.safeLoad(yaml); - return { - clusterUrl: config.clusters[0].cluster.server, - userToken: config.users[0].user.token, - } - } catch (err) { - logger.error(`[KUBE-CONFIG] Parsing config file ${chalk.bold(filePath)} failed.`, err) - return {}; - } -} diff --git a/dashboard/server/utils/logger.ts b/dashboard/server/utils/logger.ts deleted file mode 100644 index d7fbc1fc3d..0000000000 --- a/dashboard/server/utils/logger.ts +++ /dev/null @@ -1,36 +0,0 @@ -import chalk from "chalk"; -import * as ip from "ip" - -const divider = chalk.gray('-----------------------------------'); - -export const logger = { - // Called when express.js app starts on given port w/o errors - appStarted: (port: string | number, title = 'Server started ') => { - console.log(chalk.underline.bold(title) + ` ${chalk.green('✓')}`); - console.log(` - ${chalk.bold('Access URLs:')} - ${divider} - Localhost: ${chalk.magenta(`http://localhost:${port}`)} - LAN: ${chalk.magenta(`http://${ip.address()}:${port}`)} - ${divider} - `); - }, - - error(message: string, error: any) { - let errString = "" - try { - errString = JSON.stringify(error, null, 2); - } catch (e) { - errString = String(error); - } - console.error(chalk.bold.red(`[ERROR] -> ${message}`), errString); - } -}; - -export function sanitizeHeaders(headers: { [name: string]: string }) { - if (headers.Authorization) { - const [authType, authToken] = headers.Authorization.split(" "); - headers.Authorization = `${authType} *****` - } - return headers; -} diff --git a/dashboard/server/utils/parse-jwt.ts b/dashboard/server/utils/parse-jwt.ts deleted file mode 100644 index f840d65db7..0000000000 --- a/dashboard/server/utils/parse-jwt.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Parse payload from jwt token -// Format: https://github.com/kontena/kube-oidc#openid-connect-and-kubernetes -import { base64 } from "../../client/utils/base64"; - -interface JwtPayload { - "azp": string;// "1077841816959-kkdh0lvq1au80qv4gtubotvgs9am4a95.apps.googleusercontent.com", - "aud": string;// "1077841816959-kkdh0lvq1au80qv4gtubotvgs9am4a95.apps.googleusercontent.com", - "sub": string;// "103613003764490648449", - "hd": string;// "redhat.com", - "email": string;// "echiang@redhat.com", - "email_verified": boolean; // true, - "at_hash": string;// "OGDOjIJ92FkatDBoCm8ydg", - "exp": number;// 1527203940, - "iss": string;// "https://accounts.google.com", - "iat": number;// 1527200340, - "name": string; // "Eric Chiang", - "picture": string; // "https://lh5.googleusercontent.com/-Cs2iHTXiETs/AAAAAAAAAAI/AAAAAAAAACM/0Q85UhZizjg/s96-c/photo.jpg", - "given_name": string; // "Eric", - "family_name": string; //"Chiang", - "locale": string; // "en" -} - -export function parseJwt(token: string): Partial { - try { - const [header, payload, signature] = token.split("."); - return base64.decode(payload); - } catch (e) { - return {} - } -} diff --git a/dashboard/yarn.lock b/dashboard/yarn.lock index 28b14a1d52..09e219d83e 100644 --- a/dashboard/yarn.lock +++ b/dashboard/yarn.lock @@ -2631,11 +2631,6 @@ boolbase@~1.0.0: resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24= -bowser@^2.7.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.7.0.tgz#96eab1fa07fab08c1ec4c75977a7c8ddf8e0fe1f" - integrity sha512-aIlMvstvu8x+34KEiOHD3AsBgdrzg6sxALYiukOWhFvGMbQI6TRP/iY0LMhUrHs56aD6P1G0Z7h45PUJaa5m9w== - boxen@^1.2.1: version "1.3.0" resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b" @@ -2919,11 +2914,6 @@ camelcase@^5.0.0, camelcase@^5.3.1: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -camelize@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.0.tgz#164a5483e630fa4321e5af07020e531831b2609b" - integrity sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs= - caniuse-lite@^1.0.30001030: version "1.0.30001033" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001033.tgz#60c328fb56860de60f9a2cb419c31fb80587cba0" @@ -3349,11 +3339,6 @@ content-disposition@0.5.3: dependencies: safe-buffer "5.1.2" -content-security-policy-builder@2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/content-security-policy-builder/-/content-security-policy-builder-2.1.0.tgz#0a2364d769a3d7014eec79ff7699804deb8cfcbb" - integrity sha512-/MtLWhJVvJNkA9dVLAp6fg9LxD2gfI6R2Fi1hPmfjYXSahJJzcfvoeDOxSyp4NvxMuwWv3WMssE9o31DoULHrQ== - content-type@~1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" @@ -3637,11 +3622,6 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" -dasherize@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/dasherize/-/dasherize-2.0.0.tgz#6d809c9cd0cf7bb8952d80fc84fa13d47ddb1308" - integrity sha1-bYCcnNDPe7iVLYD8hPoT1H3bEwg= - data-urls@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-1.1.0.tgz#15ee0582baa5e22bb59c77140da8f9c76963bbfe" @@ -3788,11 +3768,6 @@ delegates@^1.0.0: resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= -depd@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" - integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== - depd@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" @@ -3870,11 +3845,6 @@ dns-packet@^1.3.1: ip "^1.1.0" safe-buffer "^5.0.1" -dns-prefetch-control@0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/dns-prefetch-control/-/dns-prefetch-control-0.2.0.tgz#73988161841f3dcc81f47686d539a2c702c88624" - integrity sha512-hvSnros73+qyZXhHFjx2CMLwoj3Fe7eR9EJsFsqmcI1bB2OBWL/+0YzaEaKssCHnj/6crawNnUyw74Gm2EKe+Q== - dns-txt@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/dns-txt/-/dns-txt-2.0.2.tgz#b91d806f5d27188e4ab3e7d107d881a1cc4642b6" @@ -3970,11 +3940,6 @@ domutils@^1.5.1: dom-serializer "0" domelementtype "1" -dont-sniff-mimetype@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/dont-sniff-mimetype/-/dont-sniff-mimetype-1.1.0.tgz#c7d0427f8bcb095762751252af59d148b0a623b2" - integrity sha512-ZjI4zqTaxveH2/tTlzS1wFp+7ncxNZaIEWYg3lzZRHkKf5zPT/MnEG6WL0BhHMJUabkh8GeU5NL5j+rEUCb7Ug== - dot-prop@^4.1.0: version "4.2.0" resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.0.tgz#1f19e0c2e1aa0e32797c49799f2837ac6af69c57" @@ -4354,11 +4319,6 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2: dependencies: homedir-polyfill "^1.0.1" -expect-ct@0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/expect-ct/-/expect-ct-0.2.0.tgz#3a54741b6ed34cc7a93305c605f63cd268a54a62" - integrity sha512-6SK3MG/Bbhm8MsgyJAylg+ucIOU71/FzyFalcfu5nY19dH8y/z0tBJU0wrNBXD4B27EoQtqPF/9wqH0iYAd04g== - expect@^24.9.0: version "24.9.0" resolved "https://registry.yarnpkg.com/expect/-/expect-24.9.0.tgz#b75165b4817074fa4a157794f46fe9f1ba15b6ca" @@ -4525,11 +4485,6 @@ fb-watchman@^2.0.0: dependencies: bser "^2.0.0" -feature-policy@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/feature-policy/-/feature-policy-0.3.0.tgz#7430e8e54a40da01156ca30aaec1a381ce536069" - integrity sha512-ZtijOTFN7TzCujt1fnNhfWPFPSHeZkesff9AXZj+UEjYBynWNUIYpC87Ve4wHzyexQsImicLu7WsC2LHq7/xrQ== - figgy-pudding@^3.5.1: version "3.5.1" resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.1.tgz#862470112901c727a0e495a80744bd5baa1d6790" @@ -4682,11 +4637,6 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" -frameguard@3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/frameguard/-/frameguard-3.1.0.tgz#bd1442cca1d67dc346a6751559b6d04502103a22" - integrity sha512-TxgSKM+7LTA6sidjOiSZK9wxY0ffMPY3Wta//MqwmX0nZuEHc8QrkV8Fh3ZhMJeiH+Uyh/tcaarImRy8u77O7g== - fresh@0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" @@ -5096,47 +5046,6 @@ he@1.2.x: resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== -helmet-crossdomain@0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/helmet-crossdomain/-/helmet-crossdomain-0.4.0.tgz#5f1fe5a836d0325f1da0a78eaa5fd8429078894e" - integrity sha512-AB4DTykRw3HCOxovD1nPR16hllrVImeFp5VBV9/twj66lJ2nU75DP8FPL0/Jp4jj79JhTfG+pFI2MD02kWJ+fA== - -helmet-csp@2.9.4: - version "2.9.4" - resolved "https://registry.yarnpkg.com/helmet-csp/-/helmet-csp-2.9.4.tgz#801382bac98f2f88706dc5c89d95c7e31af3a4a9" - integrity sha512-qUgGx8+yk7Xl8XFEGI4MFu1oNmulxhQVTlV8HP8tV3tpfslCs30OZz/9uQqsWPvDISiu/NwrrCowsZBhFADYqg== - dependencies: - bowser "^2.7.0" - camelize "1.0.0" - content-security-policy-builder "2.1.0" - dasherize "2.0.0" - -helmet@^3.21.2: - version "3.21.2" - resolved "https://registry.yarnpkg.com/helmet/-/helmet-3.21.2.tgz#7e2a19d5f6d898a77b5d2858e8e4bb2cda59f19f" - integrity sha512-okUo+MeWgg00cKB8Csblu8EXgcIoDyb5ZS/3u0W4spCimeVuCUvVZ6Vj3O2VJ1Sxpyb8jCDvzu0L1KKT11pkIg== - dependencies: - depd "2.0.0" - dns-prefetch-control "0.2.0" - dont-sniff-mimetype "1.1.0" - expect-ct "0.2.0" - feature-policy "0.3.0" - frameguard "3.1.0" - helmet-crossdomain "0.4.0" - helmet-csp "2.9.4" - hide-powered-by "1.1.0" - hpkp "2.0.0" - hsts "2.2.0" - ienoopen "1.1.0" - nocache "2.1.0" - referrer-policy "1.2.0" - x-xss-protection "1.3.0" - -hide-powered-by@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/hide-powered-by/-/hide-powered-by-1.1.0.tgz#be3ea9cab4bdb16f8744be873755ca663383fa7a" - integrity sha512-Io1zA2yOA1YJslkr+AJlWSf2yWFkKjvkcL9Ni1XSUqnGLr/qRQe2UI3Cn/J9MsJht7yEVCe0SscY1HgVMujbgg== - history@^4.10.1, history@^4.9.0: version "4.10.1" resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" @@ -5187,18 +5096,6 @@ hpack.js@^2.1.6: readable-stream "^2.0.1" wbuf "^1.1.0" -hpkp@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/hpkp/-/hpkp-2.0.0.tgz#10e142264e76215a5d30c44ec43de64dee6d1672" - integrity sha1-EOFCJk52IVpdMMROxD3mTe5tFnI= - -hsts@2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/hsts/-/hsts-2.2.0.tgz#09119d42f7a8587035d027dda4522366fe75d964" - integrity sha512-ToaTnQ2TbJkochoVcdXYm4HOCliNozlviNsg+X2XQLQvZNI/kCHR9rZxVYpJB3UPcHz80PgxRyWQ7PdU1r+VBQ== - dependencies: - depd "2.0.0" - html-element-map@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/html-element-map/-/html-element-map-1.1.0.tgz#e5aab9a834caf883b421f8bd9eaedcaac887d63c" @@ -5308,16 +5205,6 @@ http-proxy-middleware@0.19.1: lodash "^4.17.11" micromatch "^3.1.10" -http-proxy-middleware@^0.20.0: - version "0.20.0" - resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.20.0.tgz#5b128f7207985c4ea91b53fab8ad897a48c690d6" - integrity sha512-dNJAk71nEJhPiAczQH9hGvE/MT9kEs+zn2Dh+Hi94PGZe1GluQirC7mw5rdREUtWx6qGS1Gu0bZd4qEAg+REgw== - dependencies: - http-proxy "^1.17.0" - is-glob "^4.0.1" - lodash "^4.17.14" - micromatch "^4.0.2" - http-proxy@^1.17.0: version "1.18.0" resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.0.tgz#dbe55f63e75a347db7f3d99974f2692a314a6a3a" @@ -5372,11 +5259,6 @@ ieee754@^1.1.4: resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== -ienoopen@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/ienoopen/-/ienoopen-1.1.0.tgz#411e5d530c982287dbdc3bb31e7a9c9e32630974" - integrity sha512-MFs36e/ca6ohEKtinTJ5VvAJ6oDRAYFdYXweUnGY9L9vcoqFOU4n2ZhmJ0C4z/cwGZ3YIQRSB3XZ1+ghZkY5NQ== - iferr@^0.1.5: version "0.1.5" resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" @@ -7231,11 +7113,6 @@ no-case@^2.2.0: dependencies: lower-case "^1.1.1" -nocache@2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/nocache/-/nocache-2.1.0.tgz#120c9ffec43b5729b1d5de88cd71aa75a0ba491f" - integrity sha512-0L9FvHG3nfnnmaEQPjT9xhfN4ISk0A8/2j4M37Np4mcDesJjHgEUfgPhdCyZuFI954tjokaIj/A3NdpFNdEh4Q== - node-forge@0.9.0: version "0.9.0" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.0.tgz#d624050edbb44874adca12bb9a52ec63cb782579" @@ -8568,11 +8445,6 @@ redent@^1.0.0: indent-string "^2.1.0" strip-indent "^1.0.1" -referrer-policy@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/referrer-policy/-/referrer-policy-1.2.0.tgz#b99cfb8b57090dc454895ef897a4cc35ef67a98e" - integrity sha512-LgQJIuS6nAy1Jd88DCQRemyE3mS+ispwlqMk3b0yjZ257fI1v9c+/p6SD5gP5FGyXUIgrNOAfmyioHwZtYv2VA== - reflect.ownkeys@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz#749aceec7f3fdf8b63f927a04809e90c5c0b3460" @@ -10503,11 +10375,6 @@ ws@^6.2.1: dependencies: async-limiter "~1.0.0" -x-xss-protection@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/x-xss-protection/-/x-xss-protection-1.3.0.tgz#3e3a8dd638da80421b0e9fff11a2dbe168f6d52c" - integrity sha512-kpyBI9TlVipZO4diReZMAHWtS0MMa/7Kgx8hwG/EuZLiA6sg4Ah/4TRdASHhRRN3boobzcYgFRUFSgHRge6Qhg== - xdg-basedir@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4" diff --git a/package.json b/package.json index 9ea87de538..f2d84b039f 100644 --- a/package.json +++ b/package.json @@ -37,10 +37,6 @@ "AppImage" ], "extraResources": [ - { - "from": "binaries/server/linux/lens-server", - "to": "./lens-server.txt" - }, { "from": "binaries/client/linux/x64/kubectl", "to": "./x64/kubectl" @@ -53,10 +49,6 @@ "entitlements": "build/entitlements.mac.plist", "entitlementsInherit": "build/entitlements.mac.plist", "extraResources": [ - { - "from": "binaries/server/darwin/lens-server", - "to": "./lens-server.txt" - }, { "from": "binaries/client/darwin/x64/kubectl", "to": "./x64/kubectl" @@ -68,10 +60,6 @@ "nsis" ], "extraResources": [ - { - "from": "binaries/server/win32/lens-server-x64.exe", - "to": "./lens-server-x64.exe" - }, { "from": "binaries/server/win32/lens-server-ia32.exe", "to": "./lens-server-ia32.exe" @@ -107,7 +95,7 @@ }, "scripts": { "dev": "concurrently -n 'app,dash' 'yarn dev-electron' 'yarn dev-dashboard'", - "dev-dashboard": "cd dashboard && yarn app-dev", + "dev-dashboard": "cd dashboard && yarn dev", "dev-electron": "electron-webpack dev", "compile": "yarn download:kubectl && electron-webpack", "build:linux": "yarn compile && electron-builder --linux --dir", @@ -168,6 +156,7 @@ "devDependencies": { "@types/ejs": "^2.6.3", "@types/electron-window-state": "^2.0.31", + "@types/hapi": "^18.0.3", "@types/http-proxy": "^1.17.0", "@types/jest": "^24.0.18", "@types/jsonwebtoken": "^8.3.5", diff --git a/src/main/context-handler.ts b/src/main/context-handler.ts index 80e14474c6..3abd97ea26 100644 --- a/src/main/context-handler.ts +++ b/src/main/context-handler.ts @@ -1,16 +1,12 @@ -import { app } from "electron" import { KubeConfig } from "@kubernetes/client-node" import { readFileSync } from "fs" import * as http from "http" import { ServerOptions } from "http-proxy" import * as url from "url" -import { v4 as uuid } from "uuid" import logger from "./logger" import { getFreePort } from "./port" -import { LensServer } from "./lens-server" import { KubeAuthProxy } from "./kube-auth-proxy" import { Cluster, ClusterPreferences } from "./cluster" -import { userStore } from "../common/user-store" export class ContextHandler { public contextName: string @@ -24,14 +20,12 @@ export class ContextHandler { protected apiTarget: ServerOptions protected proxyTarget: ServerOptions protected clusterUrl: url.UrlWithStringQuery - protected localServer: LensServer protected proxyServer: KubeAuthProxy protected clientCert: string protected clientKey: string protected secureApiConnection = true protected defaultNamespace: string - protected port: number protected proxyPort: number protected kubernetesApi: string protected prometheusPath: string @@ -86,6 +80,10 @@ export class ContextHandler { } } + public getPrometheusPath() { + return this.prometheusPath + } + public async init() { const currentCluster = this.kc.getCurrentCluster() if (currentCluster.caFile) { @@ -124,41 +122,6 @@ export class ContextHandler { return this.apiTarget } - public async getProxyTarget() { - if (this.proxyTarget) { - return this.proxyTarget; - } - - this.proxyTarget = { - changeOrigin: true, - secure: false, - target: { - host: this.clusterUrl.host, - hostname: "localhost", - path: "/", - port: await this.resolvePort(), - protocol: "http://", - }, - } - - return this.proxyTarget; - } - - protected async resolvePort(): Promise { - if (this.port) return this.port - - let serverPort: number = null - try { - serverPort = await getFreePort(49153, 49900) // the proxy will usually already be on 49152 so skip that - } catch(error) { - logger.error(error) - throw(error) - } - this.port = serverPort - - return serverPort - } - protected async resolveProxyPort(): Promise { if (this.proxyPort) return this.proxyPort @@ -186,35 +149,7 @@ export class ContextHandler { } } - protected initServer(serverUrl: string, port: number) { - const userPrefs = userStore.getPreferences() - const envs = { - KUBE_CLUSTER_URL: serverUrl, - KUBE_CLUSTER_NAME: this.clusterName, - KUBERNETES_TLS_SKIP: "true", - KUBERNETES_NAMESPACE: this.defaultNamespace, - SESSION_SECRET: this.id, - LOCAL_SERVER_PORT: port.toString(), - KUBE_METRICS_URL: `${serverUrl}/api/v1/namespaces/${this.prometheusPath}/proxy`, - STATS_NAMESPACE_DEFAULT: this.prometheusPath.split("/")[0], - CHARTS_ENABLED: "true", - LENS_VERSION: app.getVersion(), - LENS_THEME: `kontena-${userPrefs.colorTheme}`, - NODE_ENV: "production", - } - logger.debug(`spinning up lens-server process with env: ${JSON.stringify(envs)}`) - this.localServer = new LensServer(serverUrl, envs) - } - public async ensureServer() { - if (!this.localServer) { - const currentCluster = this.kc.getCurrentCluster() - const clusterUrl = url.parse(currentCluster.server) - const serverPort = await this.resolvePort() - logger.info(`initializing server for ${clusterUrl.host} on port ${serverPort}`) - this.initServer(this.kubernetesApi, serverPort) - await this.localServer.run() - } if (!this.proxyServer) { const proxyPort = await this.resolveProxyPort() const proxyEnv = Object.assign({}, process.env) @@ -227,10 +162,6 @@ export class ContextHandler { } public stopServer() { - if (this.localServer) { - this.localServer.exit() - this.localServer = null - } if (this.proxyServer) { this.proxyServer.exit() this.proxyServer = null diff --git a/src/main/lens-server.ts b/src/main/lens-server.ts deleted file mode 100644 index 83192d7f4f..0000000000 --- a/src/main/lens-server.ts +++ /dev/null @@ -1,63 +0,0 @@ -import * as path from "path" -import { spawn, ChildProcess } from "child_process" -import logger from "./logger" -import * as tcpPortUsed from "tcp-port-used" - -declare const __static: string; -const isDevelopment = process.env.NODE_ENV !== "production" -let serverPath: string = null -if (isDevelopment) { - serverPath = path.join(process.cwd(), "binaries", "server", process.platform, "lens-server") -} else { - serverPath = path.join(process.resourcesPath, "lens-server") - if (process.platform !== "win32") { - serverPath = `${serverPath}.txt` - } -} -if (process.platform === "win32") { - serverPath = `${serverPath}-${process.arch}.exe` -} - - -export class LensServer { - protected serverUrl: string = null - protected env: NodeJS.ProcessEnv = null - protected localServer: ChildProcess - - constructor(serverUrl: string, env: NodeJS.ProcessEnv) { - this.serverUrl = serverUrl - this.env = env - } - - public async run(): Promise { - if (this.localServer) { - return new Promise((resolve, reject) => { - resolve() - }) - } - this.localServer = spawn(serverPath, [], { - env: this.env, - cwd: __static - }) - this.localServer.on("exit", (code) => { - logger.error(`server ${this.serverUrl} exited with code ${code}`) - this.localServer = null - }) - this.localServer.stdout.on('data', (data) => { - logger.debug(`server ${this.serverUrl} stdout: ${data}`) - }) - this.localServer.stderr.on('data', (data) => { - logger.debug(`server ${this.serverUrl} stderr: ${data}`) - }) - - return tcpPortUsed.waitUntilUsed(parseInt(this.env.LOCAL_SERVER_PORT), 500, 10000) - } - - public exit() { - if (this.localServer) { - logger.debug(`Stopping local server: ${this.serverUrl}`) - this.localServer.kill() - this.localServer = null - } - } -} diff --git a/src/main/proxy.ts b/src/main/proxy.ts index 6600b1467e..55be9321f5 100644 --- a/src/main/proxy.ts +++ b/src/main/proxy.ts @@ -35,11 +35,7 @@ export class LensProxy { this.handleRequest(proxy, req, res); }.bind(this)); proxyServer.on("upgrade", function(req: http.IncomingMessage, socket: Socket, head: Buffer) { - if (this.isRemoteShellRequired(req)) { - this.proxyWsUpgrade(proxy, req, socket, head) - } else { - this.handleWsUpgrade(req, socket, head) - } + this.handleWsUpgrade(req, socket, head) }.bind(this)); proxyServer.on("error", (err) => { @@ -135,8 +131,6 @@ export class LensProxy { delete req.headers.authorization req.url = req.url.replace("/api-kube", "") return await contextHandler.getApiTarget() - } else { - return await contextHandler.getProxyTarget() } } @@ -158,24 +152,13 @@ export class LensProxy { return } contextHandler.ensureServer().then(async () => { - if (await this.router.route(cluster, req, res)) return const proxyTarget = await this.getProxyTarget(req, contextHandler) - proxy.web(req, res, proxyTarget) - }) - } - - protected async proxyWsUpgrade(proxy: httpProxy, req: http.IncomingMessage, socket: Socket, head: Buffer) { - const cluster = this.clusterManager.getClusterForRequest(req) - const contextHandler = cluster.contextHandler - contextHandler.applyHeaders(req); - const reqUrl = url.parse(req.url, true) - const urlParams = reqUrl.query - for (const [key, value] of Object.entries(urlParams)) { - if (key !== "token") { - req.headers["x-lens-param-" + key] = value + if (proxyTarget) { + proxy.web(req, res, proxyTarget) + } else { + await this.router.route(cluster, req, res) } - } - proxy.ws(req, socket, head, await contextHandler.getProxyTarget()); + }) } protected async handleWsUpgrade(req: http.IncomingMessage, socket: Socket, head: Buffer) { @@ -187,13 +170,6 @@ export class LensProxy { wsServer.emit("connection", con, req); }); } - - protected isRemoteShellRequired(req: http.IncomingMessage) { - if (!LensProxy.localShellSessions) { - return true - } - return false; - } } export function listen(port: number, clusterManager: ClusterManager) { diff --git a/src/main/router.ts b/src/main/router.ts index 04abad4c7c..bdd51c57f0 100644 --- a/src/main/router.ts +++ b/src/main/router.ts @@ -1,13 +1,23 @@ -import * as http from "http"; -import { Cluster } from "./cluster"; +import * as http from "http" +import * as path from "path" +import { Cluster } from "./cluster" +import { configRoute } from "./routes/config" import { helmApi } from "./helm-api" import { resourceApplierApi } from "./resource-applier-api" +import { kubeconfigRoute } from "./routes/kubeconfig" +import { metricsRoute } from "./routes/metrics" +import { watchRoute } from "./routes/watch" +import { readFile } from "fs" // eslint-disable-next-line @typescript-eslint/no-var-requires const Call = require('@hapi/call'); // eslint-disable-next-line @typescript-eslint/no-var-requires const Subtext = require('@hapi/subtext'); +declare const __static: string; + +const assetsPath = path.join(__static, "build/client") + interface RouteParams { [key: string]: string | undefined; } @@ -64,7 +74,35 @@ export class Router { return request } + protected handleStaticFile(file: string, response: http.ServerResponse) { + const asset = path.join(assetsPath, file) + readFile(asset, (err, data) => { + if (err) { + response.statusCode = 404 + } else { + response.write(data) + response.end() + } + }) + } + protected addRoutes() { + // Static assets + this.router.add({ method: 'get', path: '/{path*}' }, (request: LensApiRequest) => { + const { response, params } = request + const file = params.path || "/index.html" + this.handleStaticFile(file, response) + }) + + this.router.add({ method: 'get', path: '/api/config' }, configRoute.routeConfig.bind(configRoute)) + this.router.add({ method: 'get', path: '/api/kubeconfig/service-account/{namespace}/{account}' }, kubeconfigRoute.routeServiceAccountRoute.bind(kubeconfigRoute)) + + // Watch API + this.router.add({ method: 'get', path: '/api/watch' }, watchRoute.routeWatch.bind(watchRoute)) + + // Metrics API + this.router.add({ method: 'post', path: '/api/metrics' }, metricsRoute.routeMetrics.bind(metricsRoute)) + // Helm API this.router.add({ method: 'get', path: '/api-helm/v2/charts' }, helmApi.listCharts.bind(helmApi)) this.router.add({ method: 'get', path: '/api-helm/v2/charts/{repo}/{chart}' }, helmApi.getChart.bind(helmApi)) diff --git a/src/main/routes/config.ts b/src/main/routes/config.ts new file mode 100644 index 0000000000..28b328c1a7 --- /dev/null +++ b/src/main/routes/config.ts @@ -0,0 +1,66 @@ +import { LensApiRequest } from "../router" +import { LensApi } from "../lens-api" +import { userStore } from "../../common/user-store" +import { getAppVersion } from "../../common/app-utils" +import { CoreV1Api, AuthorizationV1Api } from "@kubernetes/client-node" +import { Cluster } from "../cluster" + + +function selfSubjectAccessReview(authApi: AuthorizationV1Api, namespace: string) { + return authApi.createSelfSubjectAccessReview({ + apiVersion: "authorization.k8s.io/v1", + kind: "SelfSubjectAccessReview", + spec: { + resourceAttributes: { + namespace: namespace, + resource: "pod", + verb: "list", + } + } + }) +} + +async function getAllowedNamespaces(cluster: Cluster) { + const api = cluster.contextHandler.kc.makeApiClient(CoreV1Api) + const authApi = cluster.contextHandler.kc.makeApiClient(AuthorizationV1Api) + try { + const namespaceList = await api.listNamespace() + const nsAccessStatuses = await Promise.all( + namespaceList.body.items.map(ns => { + return selfSubjectAccessReview(authApi, ns.metadata.name) + }) + ) + return namespaceList.body.items + .filter((ns, i) => nsAccessStatuses[i].body.status.allowed) + .map(ns => ns.metadata.name) + } catch(error) { + const kc = cluster.contextHandler.kc + const ctx = kc.getContextObject(kc.currentContext) + if (ctx.namespace) { + return [ctx.namespace] + } else { + return [] + } + } +} + +class ConfigRoute extends LensApi { + + public async routeConfig(request: LensApiRequest) { + const { params, response, cluster} = request + + const data = { + clusterName: cluster.contextName, + lensVersion: getAppVersion(), + lensTheme: `kontena-${userStore.getPreferences().colorTheme}`, + kubeVersion: cluster.version, + chartsEnabled: true, + isClusterAdmin: cluster.isAdmin, + allowedNamespaces: await getAllowedNamespaces(cluster) + }; + + this.respondJson(response, data) + } +} + +export const configRoute = new ConfigRoute() diff --git a/src/main/routes/kubeconfig.ts b/src/main/routes/kubeconfig.ts new file mode 100644 index 0000000000..6a6f173f3a --- /dev/null +++ b/src/main/routes/kubeconfig.ts @@ -0,0 +1,58 @@ +import { LensApiRequest } from "../router" +import { LensApi } from "../lens-api" +import { Cluster } from "../cluster" +import { CoreV1Api, V1Secret } from "@kubernetes/client-node" + +function generateKubeConfig(username: string, secret: V1Secret, cluster: Cluster) { + const tokenData = new Buffer(secret.data["token"], "base64") + return { + 'apiVersion': 'v1', + 'kind': 'Config', + 'clusters': [ + { + 'name': cluster.contextName, + 'cluster': { + 'server': cluster.contextHandler.kc.getCurrentCluster().server, + 'certificate-authority-data': secret.data["ca.crt"] + } + } + ], + 'users': [ + { + 'name': username, + 'user': { + 'token': tokenData.toString("utf8"), + } + } + ], + 'contexts': [ + { + 'name': cluster.contextName, + 'context': { + 'user': username, + 'cluster': cluster.contextName, + 'namespace': secret.metadata.namespace, + } + } + ], + 'current-context': cluster.contextName + } +} + +class KubeconfigRoute extends LensApi { + + public async routeServiceAccountRoute(request: LensApiRequest) { + const { params, response, cluster} = request + + const client = cluster.contextHandler.kc.makeApiClient(CoreV1Api); + const secretList = await client.listNamespacedSecret(params.namespace) + const secret = secretList.body.items.find(secret => { + const { annotations } = secret.metadata; + return annotations && annotations["kubernetes.io/service-account.name"] == params.account; + }); + const data = generateKubeConfig(params.account, secret, cluster); + this.respondJson(response, data) + } +} + +export const kubeconfigRoute = new KubeconfigRoute() diff --git a/src/main/routes/metrics.ts b/src/main/routes/metrics.ts new file mode 100644 index 0000000000..51940956c6 --- /dev/null +++ b/src/main/routes/metrics.ts @@ -0,0 +1,75 @@ +import { LensApiRequest } from "../router" +import { LensApi } from "../lens-api" +import * as requestPromise from "request-promise-native" + +type MetricsQuery = string | string[] | { + [metricName: string]: string; +} + +class MetricsRoute extends LensApi { + + public async routeMetrics(request: LensApiRequest) { + const { response, cluster} = request + const query: MetricsQuery = request.payload; + const serverUrl = `http://127.0.0.1:${cluster.port}/api-kube` + const metricsUrl = `${serverUrl}/api/v1/namespaces/${cluster.contextHandler.getPrometheusPath()}/proxy/api/v1/query_range` + const headers = { + "Host": `${cluster.id}.localhost:${cluster.port}`, + "Content-type": "application/json", + } + const queryParams: MetricsQuery = {} + request.query.forEach((value: string, key: string) => { + queryParams[key] = value + }) + + // prometheus metrics loader + const attempts: { [query: string]: number } = {}; + const maxAttempts = 5; + const loadMetrics = (orgQuery: string): Promise => { + const query = orgQuery.trim() + const attempt = attempts[query] = (attempts[query] || 0) + 1; + return requestPromise(metricsUrl, { + resolveWithFullResponse: false, + headers: headers, + json: true, + qs: { + query: query, + ...queryParams + } + }).catch(async (error) => { + if (attempt < maxAttempts) { + await new Promise(resolve => setTimeout(resolve, attempt * 1000)); // add delay before repeating request + return loadMetrics(query); + } + return { + status: error.toString(), + data: { + result: [] + } + } + }) + }; + + // return data in same structure as query + let data: any; + if (typeof query === "string") { + data = await loadMetrics(query) + } + else if (Array.isArray(query)) { + data = await Promise.all(query.map(loadMetrics)); + } + else { + data = {}; + const result = await Promise.all( + Object.values(query).map(loadMetrics) + ); + Object.keys(query).forEach((metricName, index) => { + data[metricName] = result[index]; + }); + } + + this.respondJson(response, data) + } +} + +export const metricsRoute = new MetricsRoute() diff --git a/src/main/routes/watch.ts b/src/main/routes/watch.ts new file mode 100644 index 0000000000..077d70ce0f --- /dev/null +++ b/src/main/routes/watch.ts @@ -0,0 +1,108 @@ +import { LensApiRequest } from "../router" +import { LensApi } from "../lens-api" +import { Watch, KubeConfig, RuntimeRawExtension } from "@kubernetes/client-node" +import { ServerResponse } from "http" +import { Request } from "request" +import logger from "../logger" + +class ApiWatcher { + private apiUrl: string + private response: ServerResponse + private watchRequest: Request + private watch: Watch + private processor: NodeJS.Timeout + private eventBuffer: any[] = [] + + constructor(apiUrl: string, kubeConfig: KubeConfig, response: ServerResponse) { + this.apiUrl = apiUrl + this.watch = new Watch(kubeConfig) + this.response = response + } + + public start() { + if (this.processor) { + clearInterval(this.processor) + } + this.processor = setInterval(() => { + const events = this.eventBuffer.splice(0) + events.map(event => this.sendEvent(event)) + this.response.flushHeaders() + }, 50) + this.watchRequest = this.watch.watch(this.apiUrl, {}, this.watchHandler.bind(this), this.doneHandler.bind(this)) + } + + public stop() { + if (!this.watchRequest) { return } + + if (this.processor) { + clearInterval(this.processor) + } + logger.debug("Stopping watcher for api: " + this.apiUrl) + this.watchRequest.abort() + } + + private watchHandler(phase: string, obj: RuntimeRawExtension) { + this.eventBuffer.push({ + type: phase, + object: obj + }) + } + + private doneHandler(error: Error) { + if (error) { + logger.error("watch error: " + error.toString()) + this.sendEvent({ + type: "STREAM_END", + url: this.apiUrl, + status: 410, + }) + return + } + this.start() + } + + private sendEvent(evt: any) { + // convert to "text/event-stream" format + this.response.write(`data: ${JSON.stringify(evt)}\n\n`); + } +} + +class WatchRoute extends LensApi { + + public async routeWatch(request: LensApiRequest) { + const { params, response, cluster} = request + const apis: string[] = request.query.getAll("api") + const watchers: ApiWatcher[] = [] + + if (!apis.length) { + this.respondJson(response, { + message: "Empty request. Query params 'api' are not provided.", + example: "?api=/api/v1/pods&api=/api/v1/nodes", + }, 400) + return + } + + response.setHeader("Content-Type", "text/event-stream") + response.setHeader("Cache-Control", "no-cache") + response.setHeader("Connection", "keep-alive") + + apis.forEach(apiUrl => { + const watcher = new ApiWatcher(apiUrl, cluster.contextHandler.kc, response) + watcher.start() + watchers.push(watcher) + }) + + request.raw.req.on("close", () => { + logger.debug("Watch request closed") + watchers.map(watcher => watcher.stop()) + }) + + request.raw.req.on("end", () => { + logger.debug("Watch request ended") + watchers.map(watcher => watcher.stop()) + }) + + } +} + +export const watchRoute = new WatchRoute() diff --git a/yarn.lock b/yarn.lock index 6ebcbe543d..86b3a08da4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1104,11 +1104,21 @@ dependencies: "@babel/types" "^7.3.0" +"@types/boom@*": + version "7.3.0" + resolved "https://registry.yarnpkg.com/@types/boom/-/boom-7.3.0.tgz#33280c5552d4cfabc21b8b7e0f6d29292decd985" + integrity sha512-PH7bfkt1nu4pnlxz+Ws+wwJJF1HE12W3ia+Iace2JT7q56DLH3hbyjOJyNHJYRxk3PkKaC36fHfHKyeG1rMgCA== + "@types/caseless@*": version "0.12.2" resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8" integrity sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w== +"@types/catbox@*": + version "10.0.6" + resolved "https://registry.yarnpkg.com/@types/catbox/-/catbox-10.0.6.tgz#8a4c91261cf0afca03351bb82a95b2d6cf43a5d0" + integrity sha512-qS0VHlL6eBUUoUeBnI/ASCffoniS62zdV6IUtLSIjGKmRhZNawotaOMsTYivZOTZVktfe9koAJkD9XFac7tEEg== + "@types/color-name@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" @@ -1176,6 +1186,20 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/hapi@^18.0.3": + version "18.0.3" + resolved "https://registry.yarnpkg.com/@types/hapi/-/hapi-18.0.3.tgz#e74c019f6a1b1c7f647fe014d3890adec9c0214a" + integrity sha512-UM03myDZ2UWbpqLSZqboK4L98F9r4GCcd9JOr2auhgC3iOd/69mvDggivOHhOYJKWHeCW/dECPHIB7DwQz00dw== + dependencies: + "@types/boom" "*" + "@types/catbox" "*" + "@types/iron" "*" + "@types/joi" "*" + "@types/mimos" "*" + "@types/node" "*" + "@types/podium" "*" + "@types/shot" "*" + "@types/http-proxy@^1.17.0": version "1.17.0" resolved "https://registry.yarnpkg.com/@types/http-proxy/-/http-proxy-1.17.0.tgz#baf82ff6aa2723fd29f90e3ba1384e665006863e" @@ -1183,6 +1207,13 @@ dependencies: "@types/node" "*" +"@types/iron@*": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@types/iron/-/iron-5.0.1.tgz#5420bbda8623c48ee51b9a78ebad05d7305b4b24" + integrity sha512-Ng5BkVGPt7Tw9k1OJ6qYwuD9+dmnWgActmsnnrdvs4075N8V2go1f6Pz8omG3q5rbHjXN6yzzZDYo3JOgAE/Ug== + dependencies: + "@types/node" "*" + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" @@ -1215,6 +1246,11 @@ dependencies: "@types/jest-diff" "*" +"@types/joi@*": + version "14.3.4" + resolved "https://registry.yarnpkg.com/@types/joi/-/joi-14.3.4.tgz#eed1e14cbb07716079c814138831a520a725a1e0" + integrity sha512-1TQNDJvIKlgYXGNIABfgFp9y0FziDpuGrd799Q5RcnsDu+krD+eeW/0Fs5PHARvWWFelOhIG2OPCo6KbadBM4A== + "@types/js-yaml@^3.12.1": version "3.12.1" resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-3.12.1.tgz#5c6f4a1eabca84792fbd916f0cb40847f123c656" @@ -1237,6 +1273,18 @@ resolved "https://registry.yarnpkg.com/@types/md5-file/-/md5-file-4.0.0.tgz#01c986144648a757ac6e7ada665fa665c253289d" integrity sha512-t+qg7R25oYo6z3iWI+9CRky2mgQ51RGvLqlCPV+xa6dKp0YDomv0TArLK/CdcReFZwQHn/YMNRZx+4AUWXPtlg== +"@types/mime-db@*": + version "1.27.0" + resolved "https://registry.yarnpkg.com/@types/mime-db/-/mime-db-1.27.0.tgz#9bc014a1fd1fdf47649c1a54c6dd7966b8284792" + integrity sha1-m8AUof0f30dknBpUxt15ZrgoR5I= + +"@types/mimos@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/mimos/-/mimos-3.0.1.tgz#59d96abe1c9e487e7463fe41e8d86d76b57a441a" + integrity sha512-MATIRH4VMIJki8lcYUZdNQEHuAG7iQ1FWwoLgxV+4fUOly2xZYdhHtGgvQyWiTeJqq2tZbE0nOOgZD6pR0FpNQ== + dependencies: + "@types/mime-db" "*" + "@types/minimatch@*": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" @@ -1276,6 +1324,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-12.7.2.tgz#c4e63af5e8823ce9cc3f0b34f7b998c2171f0c44" integrity sha512-dyYO+f6ihZEtNPDcWNR1fkoTDf3zAK3lAABDze3mz6POyIercH0lEUawUFXlG8xaQZmm1yEBON/4TsYv/laDYg== +"@types/podium@*": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@types/podium/-/podium-1.0.0.tgz#bfaa2151be2b1d6109cc69f7faa9dac2cba3bb20" + integrity sha1-v6ohUb4rHWEJzGn3+qnawsujuyA= + "@types/proper-lockfile@^4.1.1": version "4.1.1" resolved "https://registry.yarnpkg.com/@types/proper-lockfile/-/proper-lockfile-4.1.1.tgz#99f026cbfdbe6305bdd454ffd5fefc1bd064beb1" @@ -1338,6 +1391,13 @@ "@types/glob" "*" "@types/node" "*" +"@types/shot@*": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/shot/-/shot-4.0.0.tgz#7545500c489b65c69b5bc5446ba4fef3bd26af92" + integrity sha512-Xv+n8yfccuicMlwBY58K5PVVNtXRm7uDzcwwmCarBxMP+XxGfnh1BI06YiVAsPbTAzcnYsrzpoS5QHeyV7LS8A== + dependencies: + "@types/node" "*" + "@types/stack-utils@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"