diff --git a/app/webpack.config.cjs b/app/webpack.config.cjs index 9704a4d9..b73e460f 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,19 @@ 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 f8ba618e..f10aa2e3 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 bb59db56..6f92f914 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,35 +63,44 @@ 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 - connect(connection: Connection): void + connect(connection: Connection, current?: boolean): void login(username: string, password: string): Promise 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() } + /** + * The precedence of the current connection is as follows: + * 1. URL query parameter: {@link PARAM_KEY_CONNECTION} + * 2. Session storage: {@link SESSION_KEY_CURRENT_CONNECTION} + * 3. Preset connections from the backend API: {@link PATH_PRESET_CONNECTIONS} + * (after page reload or new tabs opened) + */ private initCurrentConnection(): string | null { // Check remote connection from URL query param const url = new URL(window.location.href) @@ -110,24 +120,97 @@ 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 path = this.getPresetConnectionsPath() + const res = await fetch(path) + 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 the first connection in the current tab + // and open the rest in new tabs + const first = toOpen.shift() + toOpen.forEach(c => this.connect(c)) + if (first) { + this.connect(first, true) + } + } catch (err) { + // Silently ignore errors + log.debug('Error loading preset connections:', err) + } + } + + private getPresetConnectionsPath(): string { + const basePath = hawtio.getBasePath() + return basePath ? `${basePath}${PATH_PRESET_CONNECTIONS}` : PATH_PRESET_CONNECTIONS } 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 +308,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 { @@ -329,13 +412,18 @@ class ConnectService implements IConnectService { }) } - connect(connection: Connection) { + connect(connection: Connection, current = false) { log.debug('Connecting with options:', toString(connection)) const basepath = hawtio.getBasePath() ?? '' const url = `${basepath}/?${PARAM_KEY_CONNECTION}=${connection.id}` - log.debug('Opening URL:', url) - // let's open the same connection in the same tab (2nd parameter) - window.open(url, connection.id) + if (current) { + log.debug('Redirecting to URL:', url) + window.location.href = url + } else { + log.debug('Opening URL:', url) + // let's open the same connection in the same tab (2nd parameter) + window.open(url, connection.id) + } } /** @@ -351,13 +439,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 +509,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 +571,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 ? `${basePath}${PATH_LOGIN}` : 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 53e88dc7..730b4d7f 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?.({}) } } }