Skip to content

Commit

Permalink
feat(connect): enable auto-connect based on preset connections from b…
Browse files Browse the repository at this point in the history
  • Loading branch information
tadayosi committed Jan 6, 2025
1 parent eecda33 commit 4d56356
Show file tree
Hide file tree
Showing 4 changed files with 122 additions and 38 deletions.
17 changes: 16 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,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({
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
}

Expand Down
107 changes: 86 additions & 21 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,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<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>
Expand All @@ -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 {
Expand All @@ -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<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 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<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 +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 {
Expand Down Expand Up @@ -351,13 +416,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
}
this.createJolokia(connection, true).request(
{ type: 'version' },
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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) {
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

0 comments on commit 4d56356

Please sign in to comment.