mirror of
https://github.com/OpenNebula/one.git
synced 2025-01-20 14:03:36 +03:00
F OpenNebula/one#5763: VNC in federations (#3219)
Co-authored-by: Tino Vázquez <cvazquez@opennebula.io>
This commit is contained in:
parent
be6bcf877d
commit
1a1bc3c294
@ -13,28 +13,29 @@
|
||||
* See the License for the specific language governing permissions and *
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { memo, useMemo, useCallback, ReactElement } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useHistory, generatePath } from 'react-router-dom'
|
||||
import { ReactElement, memo, useCallback, useMemo } from 'react'
|
||||
import { generatePath, useHistory } from 'react-router-dom'
|
||||
|
||||
import {
|
||||
AppleImac2021 as VncIcon,
|
||||
TerminalOutline as SshIcon,
|
||||
Windows as RdpIcon,
|
||||
} from 'iconoir-react'
|
||||
import { SubmitButton } from 'client/components/FormControl'
|
||||
import {
|
||||
Windows as RdpIcon,
|
||||
TerminalOutline as SshIcon,
|
||||
AppleImac2021 as VncIcon,
|
||||
} from 'iconoir-react'
|
||||
|
||||
import { useViews } from 'client/features/Auth'
|
||||
import { useGeneral } from 'client/features/General'
|
||||
import { useLazyGetGuacamoleSessionQuery } from 'client/features/OneApi/vm'
|
||||
|
||||
import { PATH } from 'client/apps/sunstone/routes'
|
||||
import { Translate } from 'client/components/HOC'
|
||||
import { RESOURCE_NAMES, T, VM, VM_ACTIONS, _APPS } from 'client/constants'
|
||||
import {
|
||||
getHypervisor,
|
||||
nicsIncludesTheConnectionType,
|
||||
isAvailableAction,
|
||||
nicsIncludesTheConnectionType,
|
||||
} from 'client/models/VirtualMachine'
|
||||
import { Translate } from 'client/components/HOC'
|
||||
import { T, VM, RESOURCE_NAMES, VM_ACTIONS, _APPS } from 'client/constants'
|
||||
import { PATH } from 'client/apps/sunstone/routes'
|
||||
|
||||
const GUACAMOLE_BUTTONS = {
|
||||
[VM_ACTIONS.VNC]: { tooltip: T.Vnc, icon: <VncIcon /> },
|
||||
@ -49,22 +50,26 @@ const GuacamoleButton = memo(({ vm, connectionType, onClick }) => {
|
||||
const { icon, tooltip } = GUACAMOLE_BUTTONS[connectionType]
|
||||
const history = useHistory()
|
||||
const [getSession, { isLoading }] = useLazyGetGuacamoleSessionQuery()
|
||||
const { zone, defaultZone } = useGeneral()
|
||||
|
||||
const goToConsole = useCallback(
|
||||
async (evt) => {
|
||||
try {
|
||||
evt.stopPropagation()
|
||||
|
||||
const params = { id: vm?.ID, type: connectionType }
|
||||
|
||||
zone !== defaultZone && (params.zone = zone)
|
||||
|
||||
if (typeof onClick === 'function') {
|
||||
const session = await getSession(params).unwrap()
|
||||
onClick(session)
|
||||
} else {
|
||||
openNewBrowserTab(generatePath(PATH.GUACAMOLE, params))
|
||||
const path = `${generatePath(PATH.GUACAMOLE, params)}?zone=${zone}`
|
||||
openNewBrowserTab(path)
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
[vm?.ID, connectionType, history, onClick]
|
||||
[vm?.ID, connectionType, history, onClick, zone]
|
||||
)
|
||||
|
||||
return (
|
||||
|
@ -13,19 +13,19 @@
|
||||
* See the License for the specific language governing permissions and *
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { useRef, useEffect, RefObject } from 'react'
|
||||
import { WebSocketTunnel, Tunnel, Client } from 'guacamole-common-js'
|
||||
import { Client, Tunnel, WebSocketTunnel } from 'guacamole-common-js'
|
||||
import { RefObject, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useGeneralApi } from 'client/features/General'
|
||||
import { useGuacamole, useGuacamoleApi } from 'client/features/Guacamole'
|
||||
import { getConnectString, clientStateToString } from 'client/models/Guacamole'
|
||||
import { fakeDelay, isDevelopment } from 'client/utils'
|
||||
import {
|
||||
GUACAMOLE_STATES_STR,
|
||||
GuacamoleSession, // eslint-disable-line no-unused-vars
|
||||
SOCKETS,
|
||||
GUACAMOLE_STATES_STR,
|
||||
T,
|
||||
} from 'client/constants'
|
||||
import { useGeneralApi } from 'client/features/General'
|
||||
import { useGuacamole, useGuacamoleApi } from 'client/features/Guacamole'
|
||||
import { clientStateToString, getConnectString } from 'client/models/Guacamole'
|
||||
import { fakeDelay } from 'client/utils'
|
||||
|
||||
const {
|
||||
CONNECTING,
|
||||
@ -49,38 +49,40 @@ const {
|
||||
* @param {object} options - Client options
|
||||
* @param {string} options.id - Session includes type and VM id. Eg: '6-vnc'
|
||||
* @param {RefObject} options.display - Session display. Only exists if display plugins is enabled
|
||||
* @param {string} options.zone - zone id
|
||||
* @param {boolean} options.externalZone - is a external zone
|
||||
* @returns {GuacamoleClientType} Guacamole client props
|
||||
*/
|
||||
const GuacamoleClient = ({ id, display }) => {
|
||||
const guac = useRef(createGuacamoleClient()).current
|
||||
const GuacamoleClient = ({ id, display, zone, externalZone }) => {
|
||||
const [changeConnection, setChangeConnection] = useState(false)
|
||||
const firstZone = useRef(zone).current
|
||||
const guac = useRef(createGuacamoleClient(externalZone))
|
||||
|
||||
// Automatically update the client thumbnail
|
||||
// guac.client.onsync = () => handleUpdateThumbnail()
|
||||
if (`${firstZone}` !== `${zone}` && !changeConnection) {
|
||||
guac.current = createGuacamoleClient(externalZone)
|
||||
setChangeConnection(true)
|
||||
}
|
||||
|
||||
const { enqueueError, enqueueInfo, enqueueSuccess } = useGeneralApi()
|
||||
const { token, ...session } = useGuacamole(id)
|
||||
const {
|
||||
setConnectionState,
|
||||
setTunnelUnstable,
|
||||
setMultiTouchSupport,
|
||||
// updateThumbnail,
|
||||
} = useGuacamoleApi(id)
|
||||
const { setConnectionState, setTunnelUnstable, setMultiTouchSupport } =
|
||||
useGuacamoleApi(id)
|
||||
|
||||
const handleConnect = (width, height, force = false) => {
|
||||
if (!session?.isUninitialized && !session.isDisconnected && !force) return
|
||||
|
||||
isDevelopment() && console.log(`connect ${id} 🔵`)
|
||||
|
||||
const options = { token, display, width, height }
|
||||
|
||||
zone && (options.zone = zone)
|
||||
|
||||
const connectString = getConnectString(options)
|
||||
|
||||
guac.client.connect(connectString)
|
||||
guac.current.client.connect(connectString)
|
||||
}
|
||||
|
||||
const handleDisconnect = () => {
|
||||
try {
|
||||
isDevelopment() && console.log(`disconnect ${id} 🔴`)
|
||||
guac.client?.disconnect()
|
||||
guac.current.client?.disconnect()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@ -92,64 +94,12 @@ const GuacamoleClient = ({ id, display }) => {
|
||||
handleConnect(width, height, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the thumbnail of the given managed client within the connection
|
||||
* history under its associated ID. If the client is not connected, this
|
||||
* function has no effect.
|
||||
*/
|
||||
/* const handleUpdateThumbnail = () => {
|
||||
const nowTimestamp = new Date().getTime()
|
||||
const lastTimestamp = session?.thumbnail?.timestamp
|
||||
|
||||
if (
|
||||
lastTimestamp &&
|
||||
nowTimestamp - lastTimestamp < THUMBNAIL_UPDATE_FREQUENCY
|
||||
)
|
||||
return
|
||||
|
||||
const clientDisplay = guac.client?.getDisplay()
|
||||
|
||||
if (clientDisplay?.getWidth() <= 0 || clientDisplay?.getHeight() <= 0)
|
||||
return
|
||||
|
||||
// Get screenshot
|
||||
const canvas = clientDisplay.flatten()
|
||||
|
||||
// Calculate scale of thumbnail (max 320x240, max zoom 100%)
|
||||
const scale = Math.min(320 / canvas.width, 240 / canvas.height, 1)
|
||||
|
||||
// Create thumbnail canvas
|
||||
const thumbnail = document.createElement('canvas')
|
||||
thumbnail.width = canvas.width * scale
|
||||
thumbnail.height = canvas.height * scale
|
||||
|
||||
// Scale screenshot to thumbnail
|
||||
const context = thumbnail.getContext('2d')
|
||||
context.drawImage(
|
||||
canvas,
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
0,
|
||||
0,
|
||||
thumbnail.width,
|
||||
thumbnail.height
|
||||
)
|
||||
|
||||
thumbnail.toBlob((blob) => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const newThumbnail = { timestamp: nowTimestamp, canvas: url }
|
||||
updateThumbnail({ thumbnail: newThumbnail })
|
||||
}, 'image/webp')
|
||||
} */
|
||||
|
||||
useEffect(() => {
|
||||
guac.tunnel.onerror = (status) => {
|
||||
guac.current.tunnel.onerror = (status) => {
|
||||
setConnectionState({ state: TUNNEL_ERROR, statusCode: status.code })
|
||||
}
|
||||
|
||||
guac.tunnel.onstatechange = (state) => {
|
||||
guac.current.tunnel.onstatechange = (state) => {
|
||||
;({
|
||||
[Tunnel.State.CONNECTING]: () => {
|
||||
setConnectionState({ state: CONNECTING })
|
||||
@ -166,7 +116,7 @@ const GuacamoleClient = ({ id, display }) => {
|
||||
}[state]?.())
|
||||
}
|
||||
|
||||
guac.client.onstatechange = (state) => {
|
||||
guac.current.client.onstatechange = (state) => {
|
||||
const stateString = clientStateToString(state)
|
||||
const isDisconnect = [DISCONNECTING, DISCONNECTED].includes(stateString)
|
||||
const isDisconnected = DISCONNECTED === stateString
|
||||
@ -178,19 +128,19 @@ const GuacamoleClient = ({ id, display }) => {
|
||||
!isDisconnect && setConnectionState({ state: stateString })
|
||||
}
|
||||
|
||||
guac.client.onerror = (status) => {
|
||||
guac.current.client.onerror = (status) => {
|
||||
enqueueError(status.message)
|
||||
setConnectionState({ state: CLIENT_ERROR, statusCode: status.code })
|
||||
}
|
||||
|
||||
guac.client.onmultitouch = (layer, touches) => {
|
||||
guac.current.client.onmultitouch = (layer, touches) => {
|
||||
setMultiTouchSupport({ touches })
|
||||
}
|
||||
|
||||
return () => {
|
||||
handleDisconnect()
|
||||
}
|
||||
}, [id])
|
||||
}, [id, zone, externalZone, changeConnection])
|
||||
|
||||
useEffect(() => {
|
||||
session?.isError && handleDisconnect()
|
||||
@ -200,13 +150,14 @@ const GuacamoleClient = ({ id, display }) => {
|
||||
!session.isConnected && handleConnect()
|
||||
}, [token])
|
||||
|
||||
return { token, ...session, ...guac, handleReconnect }
|
||||
return { token, ...session, ...guac.current, handleReconnect }
|
||||
}
|
||||
|
||||
const createGuacamoleClient = () => {
|
||||
const createGuacamoleClient = (externalZone) => {
|
||||
const { protocol, host } = window.location
|
||||
const websocketProtocol = protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const guacamoleWs = `${websocketProtocol}//${host}/fireedge/${SOCKETS.GUACAMOLE}`
|
||||
const endpoint = externalZone ? SOCKETS.EXTERNAL_GUACAMOLE : SOCKETS.GUACAMOLE
|
||||
const guacamoleWs = `${websocketProtocol}//${host}/fireedge/${endpoint}`
|
||||
|
||||
const tunnel = new WebSocketTunnel(guacamoleWs)
|
||||
const client = new Client(tunnel)
|
||||
|
@ -17,7 +17,6 @@
|
||||
/* eslint-disable jsdoc/valid-types */
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { useGetLatest, reducePlugin } from 'client/components/Consoles/utils'
|
||||
import GuacamoleClient, {
|
||||
GuacamoleClientType,
|
||||
} from 'client/components/Consoles/Guacamole/client'
|
||||
@ -26,6 +25,7 @@ import {
|
||||
GuacamoleKeyboardPlugin,
|
||||
GuacamoleMousePlugin,
|
||||
} from 'client/components/Consoles/Guacamole/plugins'
|
||||
import { reducePlugin, useGetLatest } from 'client/components/Consoles/utils'
|
||||
|
||||
/**
|
||||
* Creates guacamole session.
|
||||
@ -55,6 +55,6 @@ const useGuacamoleSession = (options, ...plugins) => {
|
||||
return getInstance()
|
||||
}
|
||||
|
||||
export * from 'client/components/Consoles/Guacamole/plugins'
|
||||
export * from 'client/components/Consoles/Guacamole/buttons'
|
||||
export * from 'client/components/Consoles/Guacamole/plugins'
|
||||
export { useGuacamoleSession }
|
||||
|
@ -13,22 +13,22 @@
|
||||
* See the License for the specific language governing permissions and *
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { ReactElement, useEffect, useMemo } from 'react'
|
||||
import { Avatar, Divider, Skeleton, Stack, Typography } from '@mui/material'
|
||||
import PropTypes from 'prop-types'
|
||||
import { ReactElement, useEffect, useMemo } from 'react'
|
||||
import { useHistory } from 'react-router'
|
||||
import { Stack, Typography, Divider, Skeleton, Avatar } from '@mui/material'
|
||||
|
||||
import { useGetVmQuery } from 'client/features/OneApi/vm'
|
||||
import { useLazyGetServiceQuery } from 'client/features/OneApi/service'
|
||||
import { useGeneralApi } from 'client/features/General'
|
||||
import { StatusBadge } from 'client/components/Status'
|
||||
import { PATH } from 'client/apps/sunstone/routes'
|
||||
import { Translate } from 'client/components/HOC'
|
||||
import { OpenNebulaLogo } from 'client/components/Icons'
|
||||
import MultipleTags from 'client/components/MultipleTags'
|
||||
import { Translate } from 'client/components/HOC'
|
||||
import { getIps, getState, isVCenter } from 'client/models/VirtualMachine'
|
||||
import { StatusBadge } from 'client/components/Status'
|
||||
import { STATIC_FILES_URL, T, VM_ACTIONS } from 'client/constants'
|
||||
import { useGeneralApi } from 'client/features/General'
|
||||
import { useLazyGetServiceQuery } from 'client/features/OneApi/service'
|
||||
import { useGetVmQuery } from 'client/features/OneApi/vm'
|
||||
import { timeFromMilliseconds } from 'client/models/Helper'
|
||||
import { PATH } from 'client/apps/sunstone/routes'
|
||||
import { T, VM_ACTIONS, STATIC_FILES_URL } from 'client/constants'
|
||||
import { getIps, getState, isVCenter } from 'client/models/VirtualMachine'
|
||||
|
||||
/**
|
||||
* @param {object} props - Props
|
||||
|
@ -19,7 +19,6 @@ import { memo, useCallback, useMemo } from 'react'
|
||||
import { ConsoleButton } from 'client/components/Buttons'
|
||||
import { VirtualMachineCard } from 'client/components/Cards'
|
||||
import { VM_ACTIONS, VM_EXTENDED_POOL } from 'client/constants'
|
||||
import { useGeneral } from 'client/features/General'
|
||||
import vmApi, { useUpdateUserTemplateMutation } from 'client/features/OneApi/vm'
|
||||
import { jsonToXml } from 'client/models/Helper'
|
||||
|
||||
@ -28,9 +27,6 @@ const CONNECTION_TYPES = [VNC, RDP, SSH, VMRC]
|
||||
|
||||
const Row = memo(
|
||||
({ original, value, onClickLabel, globalErrors, ...props }) => {
|
||||
// This is for not showing VNC coneections when the user use other zone.
|
||||
const { zone, defaultZone } = useGeneral()
|
||||
|
||||
const [update] = useUpdateUserTemplateMutation()
|
||||
|
||||
const state = vmApi.endpoints.getVms.useQueryState(
|
||||
@ -64,14 +60,13 @@ const Row = memo(
|
||||
globalErrors={globalErrors}
|
||||
actions={
|
||||
<>
|
||||
{zone === defaultZone &&
|
||||
CONNECTION_TYPES.map((connectionType) => (
|
||||
<ConsoleButton
|
||||
key={`${memoVm}-${connectionType}`}
|
||||
connectionType={connectionType}
|
||||
vm={memoVm}
|
||||
/>
|
||||
))}
|
||||
{CONNECTION_TYPES.map((connectionType) => (
|
||||
<ConsoleButton
|
||||
key={`${memoVm}-${connectionType}`}
|
||||
connectionType={connectionType}
|
||||
vm={memoVm}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
@ -172,6 +172,7 @@ export const SOCKETS = {
|
||||
HOOKS: 'hooks',
|
||||
PROVISION: 'provision',
|
||||
GUACAMOLE: 'guacamole',
|
||||
EXTERNAL_GUACAMOLE: 'external-guacamole',
|
||||
VMRC: 'vmrc',
|
||||
}
|
||||
|
||||
|
@ -13,33 +13,56 @@
|
||||
* See the License for the specific language governing permissions and *
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { ReactElement, useMemo, useRef, useEffect } from 'react'
|
||||
import { useParams, useHistory } from 'react-router'
|
||||
import { Box, Stack, Container, Typography } from '@mui/material'
|
||||
import { Box, Container, Stack, Typography } from '@mui/material'
|
||||
import { ReactElement, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useHistory, useParams } from 'react-router'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { GuacamoleLogo } from 'client/components/Icons'
|
||||
import { useViews } from 'client/features/Auth'
|
||||
import { useGeneral, useGeneralApi } from 'client/features/General'
|
||||
import { useGetGuacamoleSessionQuery } from 'client/features/OneApi/vm'
|
||||
|
||||
import { PATH } from 'client/apps/sunstone/routes'
|
||||
import {
|
||||
HeaderVmInfo,
|
||||
useGuacamoleSession,
|
||||
GuacamoleDisplay,
|
||||
GuacamoleKeyboard,
|
||||
GuacamoleMouse,
|
||||
GuacamoleClipboard,
|
||||
GuacamoleCtrlAltDelButton,
|
||||
GuacamoleReconnectButton,
|
||||
GuacamoleDisplay,
|
||||
GuacamoleFullScreenButton,
|
||||
GuacamoleKeyboard,
|
||||
GuacamoleMouse,
|
||||
GuacamoleReconnectButton,
|
||||
GuacamoleScreenshotButton,
|
||||
HeaderVmInfo,
|
||||
useGuacamoleSession,
|
||||
} from 'client/components/Consoles'
|
||||
import { GuacamoleLogo } from 'client/components/Icons'
|
||||
import { PATH } from 'client/apps/sunstone/routes'
|
||||
import { Tr } from 'client/components/HOC'
|
||||
import { sentenceCase } from 'client/utils'
|
||||
import { RESOURCE_NAMES, T } from 'client/constants'
|
||||
import { sentenceCase } from 'client/utils'
|
||||
|
||||
/** @returns {ReactElement} Guacamole container */
|
||||
const Guacamole = () => {
|
||||
// set default zone for request
|
||||
const [isZoneChanged, setIsZoneChanged] = useState(false)
|
||||
|
||||
const location = useLocation()
|
||||
const searchParams = new URLSearchParams(location.search)
|
||||
const zone = searchParams.get('zone')
|
||||
|
||||
const { zone: selectedZone, defaultZone } = useGeneral()
|
||||
const { changeZone } = useGeneralApi()
|
||||
|
||||
useEffect(() => {
|
||||
const handleChangeZone = async () => {
|
||||
if (zone && zone !== selectedZone) {
|
||||
await changeZone(zone)
|
||||
}
|
||||
setIsZoneChanged(true)
|
||||
}
|
||||
|
||||
handleChangeZone()
|
||||
}, [zone, selectedZone, changeZone])
|
||||
|
||||
const containerRef = useRef(null)
|
||||
const headerRef = useRef(null)
|
||||
|
||||
@ -52,15 +75,37 @@ const Guacamole = () => {
|
||||
[view]
|
||||
)
|
||||
|
||||
const { isError: queryIsError } = useGetGuacamoleSessionQuery(
|
||||
{ id, type },
|
||||
{ refetchOnMountOrArgChange: false, skip: !isAvailableView }
|
||||
const paramsGetGuacamoleSession = { id, type }
|
||||
|
||||
const { isError: queryIsError, data } = useGetGuacamoleSessionQuery(
|
||||
paramsGetGuacamoleSession,
|
||||
{
|
||||
refetchOnMountOrArgChange: false,
|
||||
skip: !isAvailableView && !isZoneChanged,
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
;(queryIsError || !isAvailableView) && redirectTo(PATH.DASHBOARD)
|
||||
}, [queryIsError])
|
||||
|
||||
const guacamoleOption = useMemo(
|
||||
() => ({
|
||||
id: `${id}-${type}`,
|
||||
container: containerRef.current,
|
||||
header: headerRef.current,
|
||||
zone: selectedZone,
|
||||
externalZone: `${selectedZone}` !== `${defaultZone}`,
|
||||
}),
|
||||
[
|
||||
selectedZone,
|
||||
containerRef.current?.offsetWidth,
|
||||
containerRef.current?.offsetHeight,
|
||||
headerRef.current?.offsetWidth,
|
||||
headerRef.current?.offsetHeight,
|
||||
]
|
||||
)
|
||||
|
||||
const {
|
||||
token,
|
||||
clientState,
|
||||
@ -69,19 +114,7 @@ const Guacamole = () => {
|
||||
isConnected,
|
||||
...session
|
||||
} = useGuacamoleSession(
|
||||
useMemo(
|
||||
() => ({
|
||||
id: `${id}-${type}`,
|
||||
container: containerRef.current,
|
||||
header: headerRef.current,
|
||||
}),
|
||||
[
|
||||
containerRef.current?.offsetWidth,
|
||||
containerRef.current?.offsetHeight,
|
||||
headerRef.current?.offsetWidth,
|
||||
headerRef.current?.offsetHeight,
|
||||
]
|
||||
),
|
||||
guacamoleOption,
|
||||
GuacamoleDisplay,
|
||||
GuacamoleMouse,
|
||||
GuacamoleKeyboard,
|
||||
@ -118,7 +151,7 @@ const Guacamole = () => {
|
||||
gap="1em"
|
||||
padding="1em"
|
||||
>
|
||||
<HeaderVmInfo id={id} type={type} />
|
||||
{data && <HeaderVmInfo {...paramsGetGuacamoleSession} />}
|
||||
<Stack
|
||||
direction={{ sm: 'row', md: 'column' }}
|
||||
alignItems={{ sm: 'center', md: 'end' }}
|
||||
@ -126,7 +159,7 @@ const Guacamole = () => {
|
||||
flexWrap="wrap"
|
||||
gap="1em"
|
||||
>
|
||||
{connectionState && (
|
||||
{data && connectionState && (
|
||||
<Stack
|
||||
title={`${Tr(T.GuacamoleState)}: ${connectionState}`}
|
||||
flexGrow={1}
|
||||
@ -141,15 +174,17 @@ const Guacamole = () => {
|
||||
<GuacamoleLogo />
|
||||
</Stack>
|
||||
)}
|
||||
<Stack direction="row" alignItems="center" gap="1em">
|
||||
<GuacamoleReconnectButton {...session} />
|
||||
<GuacamoleScreenshotButton {...session} />
|
||||
<GuacamoleFullScreenButton {...session} />
|
||||
<GuacamoleCtrlAltDelButton {...session} />
|
||||
</Stack>
|
||||
{data && (
|
||||
<Stack direction="row" alignItems="center" gap="1em">
|
||||
<GuacamoleReconnectButton {...session} />
|
||||
<GuacamoleScreenshotButton {...session} />
|
||||
<GuacamoleFullScreenButton {...session} />
|
||||
<GuacamoleCtrlAltDelButton {...session} />
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
{displayElement}
|
||||
{data && displayElement}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
@ -35,7 +35,7 @@ export const clientStateToString = (clientState) =>
|
||||
* @returns {string} A string of connection parameters
|
||||
*/
|
||||
export const getConnectString = (options = {}) => {
|
||||
const { token, display = window, dpi, width, height } = options
|
||||
const { token, display = window, dpi, width, height, zone } = options
|
||||
|
||||
// Calculate optimal width/height for display
|
||||
const pixelDensity = window.devicePixelRatio || 1
|
||||
@ -47,10 +47,14 @@ export const getConnectString = (options = {}) => {
|
||||
const displayHeight =
|
||||
height || (isWindow(display) ? display?.innerHeight : display?.offsetHeight)
|
||||
|
||||
return [
|
||||
const urlOptions = [
|
||||
`token=${encodeURIComponent(token)}`,
|
||||
`width=${Math.floor(displayWidth * pixelDensity)}`,
|
||||
`height=${Math.floor(displayHeight * pixelDensity)}`,
|
||||
`dpi=${Math.floor(optimalDpi)}`,
|
||||
].join('&')
|
||||
]
|
||||
|
||||
zone && urlOptions.push(`zone=${zone}`)
|
||||
|
||||
return urlOptions.join('&')
|
||||
}
|
||||
|
@ -26,6 +26,8 @@ import {
|
||||
defaultHost,
|
||||
defaultPort,
|
||||
defaultWebpackMode,
|
||||
endpointExternalGuacamole,
|
||||
endpointVmrc,
|
||||
} from './utils/constants/defaults'
|
||||
import { getLoggerMiddleware, initLogger } from './utils/logger'
|
||||
import {
|
||||
@ -43,6 +45,7 @@ import { resolve } from 'path'
|
||||
import { env } from 'process'
|
||||
import webpack from 'webpack'
|
||||
import guacamole from './routes/websockets/guacamole'
|
||||
import guacamoleProxy from './routes/websockets/guacamoleProxy'
|
||||
import opennebulaWebsockets from './routes/websockets/opennebula'
|
||||
import vmrc from './routes/websockets/vmrc'
|
||||
import { messageTerminal } from './utils/general'
|
||||
@ -137,6 +140,18 @@ let config = {
|
||||
message: 'Server could not be started',
|
||||
}
|
||||
|
||||
guacamole(appServer)
|
||||
|
||||
appServer.on('upgrade', (req, socket, head) => {
|
||||
const url = req?.url
|
||||
|
||||
if (url.startsWith(endpointVmrc)) {
|
||||
vmrc.upgrade(req, socket, head)
|
||||
} else if (url.startsWith(endpointExternalGuacamole)) {
|
||||
guacamoleProxy.upgrade(req, socket, head)
|
||||
}
|
||||
})
|
||||
|
||||
appServer.listen(port, host, (err) => {
|
||||
if (!err) {
|
||||
config = {
|
||||
@ -147,8 +162,6 @@ appServer.listen(port, host, (err) => {
|
||||
}
|
||||
messageTerminal(config)
|
||||
})
|
||||
vmrc(appServer)
|
||||
guacamole(appServer)
|
||||
|
||||
/**
|
||||
* Handle sigterm and sigint.
|
||||
|
@ -365,12 +365,17 @@ const setZones = () => {
|
||||
const parsedURL = rpc && parse(rpc)
|
||||
const parsedHost = parsedURL.hostname || ''
|
||||
|
||||
return {
|
||||
const data = {
|
||||
id: oneZone.ID || '',
|
||||
name: oneZone.NAME || '',
|
||||
rpc: rpc,
|
||||
zeromq: `tcp://${parsedHost}:2101`,
|
||||
}
|
||||
|
||||
oneZone?.TEMPLATE?.FIREEDGE_ENDPOINT &&
|
||||
(data.fireedge = oneZone?.TEMPLATE?.FIREEDGE_ENDPOINT)
|
||||
|
||||
return data
|
||||
})
|
||||
}
|
||||
},
|
||||
|
@ -13,7 +13,7 @@
|
||||
* See the License for the specific language governing permissions and *
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
const { randomBytes, createCipheriv } = require('crypto')
|
||||
const { createHash, createCipheriv } = require('crypto')
|
||||
|
||||
const { defaults, httpCodes } = require('server/utils/constants')
|
||||
const {
|
||||
@ -30,7 +30,12 @@ const { USER_INFO } = userActions
|
||||
const { VM_INFO } = vmActions
|
||||
|
||||
const { ok, unauthorized, internalServerError, badRequest } = httpCodes
|
||||
const { defaultEmptyFunction, defaultCommandVM, defaultTypeCrypto } = defaults
|
||||
const {
|
||||
defaultEmptyFunction,
|
||||
defaultCommandVM,
|
||||
defaultTypeCrypto,
|
||||
defaultHash,
|
||||
} = defaults
|
||||
|
||||
const appConfig = getSunstoneConfig()
|
||||
const prependCommand = appConfig.sunstone_prepend || ''
|
||||
@ -116,63 +121,72 @@ const generateGuacamoleSession = (
|
||||
const { username } = serverAdmin
|
||||
const oneClient = xmlrpc(`${username}:${username}`, authToken)
|
||||
|
||||
const callbackVmInfo = (vmInfoErr, VM, USER) => {
|
||||
if (vmInfoErr || !VM) {
|
||||
res.locals.httpCode = httpResponse(
|
||||
!VM ? internalServerError : unauthorized,
|
||||
vmInfoErr
|
||||
)
|
||||
next()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const settings = {
|
||||
vnc: () => getVncSettings(VM),
|
||||
ssh: () => getSshSettings(VM, USER),
|
||||
rdp: () => getRdpSettings(VM),
|
||||
}[ensuredType]?.() ?? { error: '' }
|
||||
|
||||
if (settings.error) {
|
||||
res.locals.httpCode = httpResponse(badRequest, settings.error)
|
||||
next()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const connection = {
|
||||
// expiration,
|
||||
connection: {
|
||||
type: ensuredType,
|
||||
settings: {
|
||||
security: 'any',
|
||||
'ignore-cert': 'true',
|
||||
'enable-drive': 'true',
|
||||
'create-drive-path': 'true',
|
||||
...settings,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const wsToken = JSON.stringify(encryptConnection(connection))
|
||||
const encodedWsToken = Buffer.from(wsToken).toString('base64')
|
||||
|
||||
res.locals.httpCode = httpResponse(ok, encodedWsToken)
|
||||
next()
|
||||
}
|
||||
|
||||
const callbackUserInfo = (userInfoErr, { USER } = {}) => {
|
||||
if (userInfoErr || !USER) {
|
||||
res.locals.httpCode = httpResponse(badRequest, userInfoErr)
|
||||
next()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// get VM information by id
|
||||
oneClient({
|
||||
action: VM_INFO,
|
||||
parameters: [parseInt(vmId, 10), true],
|
||||
callback: (vmInfoErr, { VM } = {}) => callbackVmInfo(vmInfoErr, VM, USER),
|
||||
})
|
||||
}
|
||||
|
||||
// get authenticated user
|
||||
oneClient({
|
||||
action: USER_INFO,
|
||||
parameters: [parseInt(userAuthId, 10), true],
|
||||
callback: (userInfoErr, { USER } = {}) => {
|
||||
if (userInfoErr || !USER) {
|
||||
res.locals.httpCode = httpResponse(badRequest, userInfoErr)
|
||||
next()
|
||||
}
|
||||
|
||||
// get VM information by id
|
||||
oneClient({
|
||||
action: VM_INFO,
|
||||
parameters: [parseInt(vmId, 10), true],
|
||||
callback: (vmInfoErr, { VM } = {}) => {
|
||||
if (vmInfoErr || !VM) {
|
||||
res.locals.httpCode = httpResponse(unauthorized, vmInfoErr)
|
||||
next()
|
||||
}
|
||||
|
||||
const settings = {
|
||||
vnc: () => getVncSettings(VM),
|
||||
ssh: () => getSshSettings(VM, USER),
|
||||
rdp: () => getRdpSettings(VM),
|
||||
}[ensuredType]?.() ?? { error: '' }
|
||||
|
||||
if (settings.error) {
|
||||
res.locals.httpCode = httpResponse(badRequest, settings.error)
|
||||
next()
|
||||
}
|
||||
|
||||
// const minutesToAdd = 1
|
||||
// const currentDate = new Date()
|
||||
// const expiration = currentDate.getTime() + minutesToAdd * 60000
|
||||
|
||||
const connection = {
|
||||
// expiration,
|
||||
connection: {
|
||||
type: ensuredType,
|
||||
settings: {
|
||||
security: 'any',
|
||||
'ignore-cert': 'true',
|
||||
'enable-drive': 'true',
|
||||
'create-drive-path': 'true',
|
||||
...settings,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const wsToken = JSON.stringify(encryptConnection(connection))
|
||||
const encodedWsToken = Buffer.from(wsToken).toString('base64')
|
||||
|
||||
res.locals.httpCode = httpResponse(ok, encodedWsToken)
|
||||
next()
|
||||
},
|
||||
})
|
||||
},
|
||||
callback: callbackUserInfo,
|
||||
})
|
||||
}
|
||||
|
||||
@ -296,10 +310,11 @@ const getRdpSettings = (vmInfo) => {
|
||||
}
|
||||
|
||||
const encryptConnection = (data) => {
|
||||
const iv = randomBytes(16)
|
||||
const { hash, digest } = defaultHash
|
||||
const key = global.paths.FIREEDGE_KEY
|
||||
const keyBuffer = Buffer.from(key, digest)
|
||||
const iv = createHash(hash).update(keyBuffer).digest().slice(0, 16)
|
||||
const cipher = createCipheriv(defaultTypeCrypto, key, iv)
|
||||
|
||||
const ensuredData = typeof data === 'string' ? data : JSON.stringify(data)
|
||||
let value = cipher.update(ensuredData, 'utf-8', 'base64')
|
||||
value += cipher.final('base64')
|
||||
|
@ -21,7 +21,7 @@ const {
|
||||
|
||||
const basepath = '/vm'
|
||||
const { POST, GET } = httpMethod
|
||||
const { resource, postBody } = fromData
|
||||
const { resource, postBody, query } = fromData
|
||||
|
||||
const VM_SAVEASTEMPLATE = 'vm.saveastemplate'
|
||||
const GUACAMOLE = 'vm.guacamole'
|
||||
@ -61,6 +61,9 @@ module.exports = {
|
||||
type: {
|
||||
from: resource,
|
||||
},
|
||||
zone: {
|
||||
from: query,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
79
src/fireedge/src/server/routes/websockets/guacamoleProxy.js
Normal file
79
src/fireedge/src/server/routes/websockets/guacamoleProxy.js
Normal file
@ -0,0 +1,79 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2024, OpenNebula Project, OpenNebula Systems *
|
||||
* *
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
|
||||
* not use this file except in compliance with the License. You may obtain *
|
||||
* a copy of the License at *
|
||||
* *
|
||||
* http://www.apache.org/licenses/LICENSE-2.0 *
|
||||
* *
|
||||
* Unless required by applicable law or agreed to in writing, software *
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, *
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
|
||||
* See the License for the specific language governing permissions and *
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
// eslint-disable-next-line node/no-deprecated-api
|
||||
const { parse } = require('url')
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware')
|
||||
const { getFireedgeConfig } = require('server/utils/yml')
|
||||
const { genPathResources } = require('server/utils/server')
|
||||
const { getZone } = require('server/routes/entrypoints/Api/middlawares')
|
||||
const {
|
||||
endpointExternalGuacamole,
|
||||
defaultPort,
|
||||
defaultProtocol,
|
||||
} = require('server/utils/constants/defaults')
|
||||
|
||||
genPathResources()
|
||||
|
||||
const appConfig = getFireedgeConfig()
|
||||
const port = appConfig.port || defaultPort
|
||||
const protocol = defaultProtocol
|
||||
const url = `${protocol}://localhost:${port}`
|
||||
|
||||
const getZoneUrl = (req) => {
|
||||
let zoneURL = ''
|
||||
const externalURL = req?.url
|
||||
if (externalURL) {
|
||||
const parsedURL = parse(externalURL, true)
|
||||
const zone = parsedURL?.query?.zone
|
||||
|
||||
if (zone) {
|
||||
const dataZone = getZone(zone)
|
||||
if (dataZone?.fireedge) {
|
||||
zoneURL = dataZone?.fireedge
|
||||
} else {
|
||||
const URL = dataZone.rpc
|
||||
const zoneDataURL = parse(URL)
|
||||
zoneURL = `${protocol}://${zoneDataURL.hostname}:${defaultPort}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return zoneURL
|
||||
}
|
||||
|
||||
const setHeaders = (proxyReq, req) => {
|
||||
Object.keys(req.headers).forEach((header) => {
|
||||
if (header.toLowerCase().startsWith('sec-websocket-')) {
|
||||
proxyReq.setHeader(header, req.headers[header])
|
||||
}
|
||||
})
|
||||
const externalURL = getZoneUrl(req)
|
||||
externalURL && proxyReq.setHeader('Origin', externalURL)
|
||||
}
|
||||
|
||||
const guacamoleProxy = createProxyMiddleware(endpointExternalGuacamole, {
|
||||
target: url,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
secure: /^(https):\/\/[^ "]+$/.test(url),
|
||||
pathRewrite: (path) => path.replace('/external-guacamole', '/guacamole'),
|
||||
onProxyReqWs: setHeaders,
|
||||
onProxyReq: setHeaders,
|
||||
router: (req) => getZoneUrl(req),
|
||||
})
|
||||
|
||||
module.exports = guacamoleProxy
|
@ -38,7 +38,7 @@ const appConfig = getFireedgeConfig()
|
||||
const port = appConfig.port || defaultPort
|
||||
const protocol = defaultProtocol
|
||||
const url = `${protocol}://localhost:${port}`
|
||||
const vmrcProxy = createProxyMiddleware(endpointVmrc, {
|
||||
const vmrc = createProxyMiddleware(endpointVmrc, {
|
||||
target: url,
|
||||
changeOrigin: false,
|
||||
ws: true,
|
||||
@ -112,15 +112,4 @@ const vmrcProxy = createProxyMiddleware(endpointVmrc, {
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* VMRC Proxy.
|
||||
*
|
||||
* @param {object} appServer - express app
|
||||
*/
|
||||
const vmrc = (appServer) => {
|
||||
if (appServer && appServer?.on && appServer?.constructor?.name === 'Server') {
|
||||
appServer.on('upgrade', vmrcProxy.upgrade)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = vmrc
|
||||
|
@ -144,6 +144,7 @@ const defaults = {
|
||||
defaultBaseURL: '',
|
||||
endpointVmrc: `${baseUrl}vmrc`,
|
||||
endpointGuacamole: `${baseUrl}guacamole`,
|
||||
endpointExternalGuacamole: `${baseUrl}external-guacamole`,
|
||||
defaultNamespace: 'one',
|
||||
defaultMessageInvalidZone: 'Invalid Zone',
|
||||
default2FAIssuer: `${appName}-UI`,
|
||||
|
Loading…
x
Reference in New Issue
Block a user