Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(connect): enable auto-connect based on preset connections from backend #1286

Merged
merged 3 commits into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion app/webpack.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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({
Expand Down
10 changes: 7 additions & 3 deletions packages/hawtio/src/plugins/shared/__mocks__/connect-service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -16,6 +16,10 @@ class MockConnectService implements IConnectService {
console.log('Using mock connect service')
}

getCurrentConnectionId(): string | null {
return null
}

getCurrentConnectionName(): string | null {
return null
}
Expand Down Expand Up @@ -72,7 +76,7 @@ class MockConnectService implements IConnectService {
return ''
}

getJolokiaUrlFromName(name: string): string | null {
getJolokiaUrlFromId(name: string): string | null {
return null
tadayosi marked this conversation as resolved.
Show resolved Hide resolved
}

Expand Down
134 changes: 108 additions & 26 deletions packages/hawtio/src/plugins/shared/connect-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
tadayosi marked this conversation as resolved.
Show resolved Hide resolved

export interface IConnectService {
getCurrentConnectionId(): string | null
getCurrentConnectionName(): string | null
getCurrentConnection(): Promise<Connection | null>
getCurrentCredentials(): Promise<ConnectionCredentials | null>
loadConnections(): Connections
saveConnections(connections: Connections): void
getConnection(name: string): Connection | null
getConnection(id: string): Connection | null
connectionToUrl(connection: Connection): string
checkReachable(connection: Connection): Promise<ConnectStatus>
testConnection(connection: Connection): Promise<ConnectionTestResult>
connect(connection: Connection): void
connect(connection: Connection, current?: boolean): void
login(username: string, password: string): Promise<LoginResult>
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)
Expand All @@ -110,24 +120,91 @@ 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<void> {
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<Connection>[] = 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)
}
}

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<Connection | null> {
const conn = this.currentConnection ? this.getConnection(this.currentConnection) : null
const id = this.currentConnectionId
const conn = id ? this.getConnection(id) : null
if (!conn) {
return null
}
Expand Down Expand Up @@ -225,9 +302,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 {
Expand Down Expand Up @@ -329,13 +406,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)
}
}

/**
Expand All @@ -351,13 +433,13 @@ class ConnectService implements IConnectService {
const result = await new Promise<LoginResult>(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<string, string> = {
'X-Jolokia-Authorization': basicAuthHeaderValue(username, password),
}
const token = getCookie('XSRF-TOKEN')
if (token) {
// For CSRF protection with Spring Security
;(headers as Record<string, string>)['X-XSRF-TOKEN'] = token
headers['X-XSRF-TOKEN'] = token
tadayosi marked this conversation as resolved.
Show resolved Hide resolved
}
this.createJolokia(connection, true).request(
{ type: 'version' },
Expand Down Expand Up @@ -421,7 +503,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')
Expand Down Expand Up @@ -483,16 +565,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) {
Expand Down
26 changes: 13 additions & 13 deletions packages/hawtio/src/plugins/shared/jolokia-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -335,26 +335,24 @@ class JolokiaService implements IJolokiaService {
if (!options.headers) {
options.headers = {}
}
const headers: Record<string, string> = options.headers as Record<string, string>

// Set Authorization header depending on current setup
if ((await userService.isLogin()) && userService.getToken()) {
log.debug('Set authorization header to token')
;(options.headers as Record<string, string>)['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<string, string>)['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<string, string>)['X-XSRF-TOKEN'] = token
headers['X-XSRF-TOKEN'] = token
}
}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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?.({})
}
}
}
Expand Down
Loading