From 4d563562cee7ba34428558c51e1c1bffaba35df7 Mon Sep 17 00:00:00 2001 From: Tadayoshi Sato Date: Mon, 6 Jan 2025 15:09:47 +0900 Subject: [PATCH] feat(connect): enable auto-connect based on preset connections from backend hawtio/hawtio#3731 --- app/webpack.config.cjs | 17 ++- .../shared/__mocks__/connect-service.ts | 10 +- .../src/plugins/shared/connect-service.ts | 107 ++++++++++++++---- .../src/plugins/shared/jolokia-service.ts | 26 ++--- 4 files changed, 122 insertions(+), 38 deletions(-) diff --git a/app/webpack.config.cjs b/app/webpack.config.cjs index 9704a4d92..5aaa9240c 100644 --- a/app/webpack.config.cjs +++ b/app/webpack.config.cjs @@ -163,7 +163,11 @@ module.exports = (_, args) => { // Hawtio backend API mock let login = true devServer.app.get(`${publicPath}/user`, (_, res) => { - login ? res.send(`"${username}"`) : res.sendStatus(403) + if (login) { + res.send(`"${username}"`) + } else { + res.sendStatus(403) + } }) devServer.app.post(`${publicPath}/auth/login`, (req, res) => { // Test authentication throttling with username 'throttled' @@ -213,6 +217,17 @@ module.exports = (_, args) => { ) devServer.app.get(`${publicPath}/keycloak/validate-subject-matches`, (_, res) => res.send('true')) + // Testing preset connections + devServer.app.get(`${publicPath}/preset-connections`, (_, res) => { + res.type('application/json') + res.send( + JSON.stringify([ + { name: 'test1', scheme: 'http', host: 'localhost', port: 8778, path: '/jolokia/' }, + { name: 'test2' }, + ]), + ) + }) + // Hawtio backend middleware should be run before other middlewares (thus 'unshift') // in order to handle GET requests to the proxied Jolokia endpoint. middlewares.unshift({ diff --git a/packages/hawtio/src/plugins/shared/__mocks__/connect-service.ts b/packages/hawtio/src/plugins/shared/__mocks__/connect-service.ts index f8ba618e0..f10aa2e39 100644 --- a/packages/hawtio/src/plugins/shared/__mocks__/connect-service.ts +++ b/packages/hawtio/src/plugins/shared/__mocks__/connect-service.ts @@ -1,13 +1,13 @@ -import 'jolokia.js' import Jolokia, { IJolokiaSimple } from '@jolokia.js/simple' +import 'jolokia.js' import { + ConnectStatus, Connection, ConnectionCredentials, ConnectionTestResult, Connections, IConnectService, LoginResult, - ConnectStatus, } from '../connect-service' class MockConnectService implements IConnectService { @@ -16,6 +16,10 @@ class MockConnectService implements IConnectService { console.log('Using mock connect service') } + getCurrentConnectionId(): string | null { + return null + } + getCurrentConnectionName(): string | null { return null } @@ -72,7 +76,7 @@ class MockConnectService implements IConnectService { return '' } - getJolokiaUrlFromName(name: string): string | null { + getJolokiaUrlFromId(name: string): string | null { return null } diff --git a/packages/hawtio/src/plugins/shared/connect-service.ts b/packages/hawtio/src/plugins/shared/connect-service.ts index bb59db569..6424a330d 100644 --- a/packages/hawtio/src/plugins/shared/connect-service.ts +++ b/packages/hawtio/src/plugins/shared/connect-service.ts @@ -5,6 +5,7 @@ import { toString } from '@hawtiosrc/util/strings' import { joinPaths } from '@hawtiosrc/util/urls' import Jolokia, { IJolokiaSimple } from '@jolokia.js/simple' import { log } from './globals' + export type Connections = { // key is ID, not name, so we can alter the name [key: string]: Connection @@ -62,15 +63,17 @@ export const SESSION_KEY_CURRENT_CONNECTION = 'connect.currentConnection' export const PARAM_KEY_CONNECTION = 'con' export const PARAM_KEY_REDIRECT = 'redirect' -const LOGIN_PATH = '/connect/login' +const PATH_LOGIN = '/connect/login' +const PATH_PRESET_CONNECTIONS = 'preset-connections' export interface IConnectService { + getCurrentConnectionId(): string | null getCurrentConnectionName(): string | null getCurrentConnection(): Promise getCurrentCredentials(): Promise loadConnections(): Connections saveConnections(connections: Connections): void - getConnection(name: string): Connection | null + getConnection(id: string): Connection | null connectionToUrl(connection: Connection): string checkReachable(connection: Connection): Promise testConnection(connection: Connection): Promise @@ -79,16 +82,16 @@ export interface IConnectService { redirect(): void createJolokia(connection: Connection, checkCredentials?: boolean): IJolokiaSimple getJolokiaUrl(connection: Connection): string - getJolokiaUrlFromName(name: string): string | null + getJolokiaUrlFromId(name: string): string | null getLoginPath(): string export(connections: Connections): void } class ConnectService implements IConnectService { - private readonly currentConnection: string | null + private readonly currentConnectionId: string | null constructor() { - this.currentConnection = this.initCurrentConnection() + this.currentConnectionId = this.initCurrentConnection() } private initCurrentConnection(): string | null { @@ -110,24 +113,86 @@ class ConnectService implements IConnectService { // Case when user may refresh the page after "con" parameter has already been cleared // Check remote connection from session storage conn = sessionStorage.getItem(SESSION_KEY_CURRENT_CONNECTION) - return conn ? JSON.parse(conn) : null + if (conn) { + return JSON.parse(conn) + } + + // Processing preset connections should come at last to prevent processing + // them multiple times, because it may open new tab(s)/session(s) with `?con=` + // to auto-connect to them later. + this.loadPresetConnections() + + return null + } + + /** + * See: https://github.com/hawtio/hawtio/issues/3731 + */ + private async loadPresetConnections(): Promise { + try { + const res = await fetch(PATH_PRESET_CONNECTIONS) + if (!res.ok) { + log.debug('Failed to load preset connections:', res.status, res.statusText) + return + } + + const preset: Partial[] = await res.json() + log.debug('Preset connections:', preset) + const connections = this.loadConnections() + const toOpen: Connection[] = [] + preset.forEach(({ name, scheme, host, port, path }) => { + // name must be always defined + if (!name) { + return + } + let conn = Object.values(connections).find(c => c.name === name) + if (scheme && host && port && path) { + if (port < 0) { + // default ports + port = scheme === 'https' ? 443 : 80 + } + if (!conn) { + conn = { id: '', name, scheme, host, port, path } + this.generateId(conn, connections) + connections[conn.id] = conn + } else { + conn.scheme = scheme + conn.host = host + conn.port = port + conn.path = path + } + toOpen.push(conn) + } else if (conn) { + // Open connection only when it exists + toOpen.push(conn) + } + }) + this.saveConnections(connections) + + // Open connections in new tabs + toOpen.forEach(c => this.connect(c)) + } catch (err) { + // Silently ignore errors + log.debug('Error loading preset connections:', err) + } } getCurrentConnectionId(): string | null { - return this.currentConnection + return this.currentConnectionId } getCurrentConnectionName(): string | null { - const id = this.currentConnection - const connections = this.loadConnections() - if (!id || !connections[id!]) { + const id = this.currentConnectionId + if (!id) { return null } - return connections[id!]!.name ?? null + const connection = this.getConnection(id) + return connection?.name ?? null } async getCurrentConnection(): Promise { - const conn = this.currentConnection ? this.getConnection(this.currentConnection) : null + const id = this.currentConnectionId + const conn = id ? this.getConnection(id) : null if (!conn) { return null } @@ -225,9 +290,9 @@ class ConnectService implements IConnectService { } } - getConnection(name: string): Connection | null { + getConnection(id: string): Connection | null { const connections = this.loadConnections() - return connections[name] ?? null + return connections[id] ?? null } connectionToUrl(connection: Connection): string { @@ -351,13 +416,13 @@ class ConnectService implements IConnectService { const result = await new Promise(resolve => { // this special header is used to pass credentials to remote Jolokia agent when // Authorization header is already "taken" by OIDC/Keycloak authenticator - const headers = { + const headers: Record = { 'X-Jolokia-Authorization': basicAuthHeaderValue(username, password), } const token = getCookie('XSRF-TOKEN') if (token) { // For CSRF protection with Spring Security - ;(headers as Record)['X-XSRF-TOKEN'] = token + headers['X-XSRF-TOKEN'] = token } this.createJolokia(connection, true).request( { type: 'version' }, @@ -421,7 +486,7 @@ class ConnectService implements IConnectService { port === url.port && ['http:', 'https:'].includes(protocol) && connectionKey !== '' && - connectionKey === this.currentConnection + connectionKey === this.currentConnectionId // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (_e) { log.error('Invalid URL') @@ -483,16 +548,16 @@ class ConnectService implements IConnectService { } /** - * Get the Jolokia URL for the given connection name. + * Get the Jolokia URL for the given connection ID. */ - getJolokiaUrlFromName(name: string): string | null { - const connection = this.getConnection(name) + getJolokiaUrlFromId(id: string): string | null { + const connection = this.getConnection(id) return connection ? this.getJolokiaUrl(connection) : null } getLoginPath(): string { const basePath = hawtio.getBasePath() - return `${basePath}${LOGIN_PATH}` + return `${basePath}${PATH_LOGIN}` } export(connections: Connections) { diff --git a/packages/hawtio/src/plugins/shared/jolokia-service.ts b/packages/hawtio/src/plugins/shared/jolokia-service.ts index 53e88dc7f..730b4d7f5 100644 --- a/packages/hawtio/src/plugins/shared/jolokia-service.ts +++ b/packages/hawtio/src/plugins/shared/jolokia-service.ts @@ -12,6 +12,7 @@ import { } from '@hawtiosrc/util/jolokia' import { isObject, isString } from '@hawtiosrc/util/objects' import { parseBoolean } from '@hawtiosrc/util/strings' +import Jolokia, { IJolokiaSimple, SimpleRequestOptions, SimpleResponseCallback } from '@jolokia.js/simple' import { BaseRequestOptions, ErrorCallback, @@ -33,7 +34,6 @@ import { VersionResponseValue, WriteResponseValue, } from 'jolokia.js' -import Jolokia, { IJolokiaSimple, SimpleRequestOptions, SimpleResponseCallback } from '@jolokia.js/simple' import { is, object } from 'superstruct' import { PARAM_KEY_CONNECTION, @@ -237,10 +237,10 @@ class JolokiaService implements IJolokiaService { } // Check remote connection - const conn = connectService.getCurrentConnectionId() - if (conn) { - log.debug('Connection provided, not discovering Jolokia: con =', conn) - return connectService.getJolokiaUrlFromName(conn) + const connId = connectService.getCurrentConnectionId() + if (connId) { + log.debug('Connection provided, not discovering Jolokia: con =', connId) + return connectService.getJolokiaUrlFromId(connId) } // Discover Jolokia @@ -335,26 +335,24 @@ class JolokiaService implements IJolokiaService { if (!options.headers) { options.headers = {} } + const headers: Record = options.headers as Record // Set Authorization header depending on current setup if ((await userService.isLogin()) && userService.getToken()) { log.debug('Set authorization header to token') - ;(options.headers as Record)['Authorization'] = `Bearer ${userService.getToken()}` + headers['Authorization'] = `Bearer ${userService.getToken()}` } // for remote Jolokia authorization, we'll always use X-Jolokia-Authorization header if (connection && connection.username && connection.password) { - ;(options.headers as Record)['X-Jolokia-Authorization'] = basicAuthHeaderValue( - connection.username, - connection.password, - ) + headers['X-Jolokia-Authorization'] = basicAuthHeaderValue(connection.username, connection.password) } const token = getCookie('XSRF-TOKEN') if (token) { // For CSRF protection with Spring Security log.debug('Set XSRF token header from cookies') - ;(options.headers as Record)['X-XSRF-TOKEN'] = token + headers['X-XSRF-TOKEN'] = token } } @@ -780,7 +778,8 @@ class JolokiaService implements IJolokiaService { const fetchError = options.fetchError options.fetchError = (response: Response | null, error: DOMException | TypeError | string | null): void => { if (typeof fetchError === 'function') { - ;(fetchError as FetchErrorCallback)?.(response, error) + const callback = fetchError as FetchErrorCallback + callback?.(response, error) } // reject the relevant promise on any HTTP/communication error reject() @@ -1104,7 +1103,8 @@ class DummyJolokia implements IJolokiaSimple { typeof params[params.length - 1] === 'object' && 'success' in (params[params.length - 1] as SimpleRequestOptions) ) { - ;(params[params.length - 1] as SimpleRequestOptions)?.success?.({}) + const options = params[params.length - 1] as SimpleRequestOptions + options.success?.({}) } } }