diff --git a/client/config/routes.ts b/client/config/routes.ts index 0c22e0c0..1aa28307 100644 --- a/client/config/routes.ts +++ b/client/config/routes.ts @@ -85,4 +85,12 @@ export default [ path: '/manage/playbooks/:id', component: './Playbooks', }, + { + path: '/manage/services/logs/:id', + component: './Services/logs/Logs', + }, + { + path: '/manage/devices/ssh/:id', + component: './Devices/DeviceSSHTerminal', + }, ]; diff --git a/client/package-lock.json b/client/package-lock.json index 789b2d77..039b5253 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -20,6 +20,8 @@ "@umijs/max": "^4.3.11", "@umijs/plugin-antd-dayjs": "^0.3.0", "@umijs/route-utils": "^4.0.1", + "@xterm/addon-fit": "^0.10.0", + "anser": "^2.1.1", "antd": "^5.20.0", "classnames": "^2.5.1", "dayjs": "^1.11.12", @@ -41,8 +43,10 @@ "react-js-cron": "^5.0.1", "react-json-formatter": "^0.4.0", "react-terminal": "^1.4.4", + "socket.io-client": "^4.7.5", "ssm-shared-lib": "file:../shared-lib/", - "umi-presets-pro": "^2.0.3" + "umi-presets-pro": "^2.0.3", + "xterm": "^5.3.0" }, "devDependencies": { "@ant-design/plots": "^2.2.8", @@ -8310,6 +8314,12 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@stylelint/postcss-css-in-js": { "version": "0.38.0", "resolved": "https://registry.npmjs.org/@stylelint/postcss-css-in-js/-/postcss-css-in-js-0.38.0.tgz", @@ -20775,6 +20785,22 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT", + "peer": true + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -21020,6 +21046,12 @@ "node": ">=0.4.2" } }, + "node_modules/anser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/anser/-/anser-2.1.1.tgz", + "integrity": "sha512-nqLm4HxOTpeLOxcmB3QWmV5TcDFhW9y/fyQ+hivtDFcK4OQ+pQ5fzPnXHM1Mfcm0VkLtvVi1TCPr++Qy0Q/3EQ==", + "license": "MIT" + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -26344,6 +26376,49 @@ "once": "^1.4.0" } }, + "node_modules/engine.io-client": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.4.tgz", + "integrity": "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.0.0" + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.16.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.16.0.tgz", @@ -41508,6 +41583,34 @@ "npm": ">= 3.0.0" } }, + "node_modules/socket.io-client": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz", + "integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/socks": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.1.tgz", @@ -46119,6 +46222,14 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xstate": { "version": "4.38.3", "resolved": "https://registry.npmjs.org/xstate/-/xstate-4.38.3.tgz", @@ -46137,6 +46248,13 @@ "node": ">=0.4" } }, + "node_modules/xterm": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", + "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==", + "deprecated": "This package is now deprecated. Move to @xterm/xterm instead.", + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/client/package.json b/client/package.json index 7417e204..7528459e 100644 --- a/client/package.json +++ b/client/package.json @@ -80,9 +80,11 @@ "react-helmet-async": "^2.0.5", "react-js-cron": "^5.0.1", "react-json-formatter": "^0.4.0", - "react-terminal": "^1.4.4", "ssm-shared-lib": "file:../shared-lib/", - "umi-presets-pro": "^2.0.3" + "umi-presets-pro": "^2.0.3", + "socket.io-client": "^4.7.5", + "xterm": "^5.3.0", + "@xterm/addon-fit": "^0.10.0" }, "devDependencies": { "@ant-design/plots": "^2.2.8", diff --git a/client/src/app.tsx b/client/src/app.tsx index 4f28a199..d4232c40 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -81,9 +81,7 @@ export const layout: RunTimeLayoutConfig = ({ , , , - , + , ], avatarProps: { src: initialState?.currentUser?.avatar, diff --git a/client/src/components/DeviceComponents/DeviceQuickAction/DeviceQuickActionDropDown.tsx b/client/src/components/DeviceComponents/DeviceQuickAction/DeviceQuickActionDropDown.tsx index 4e9c1152..56789412 100644 --- a/client/src/components/DeviceComponents/DeviceQuickAction/DeviceQuickActionDropDown.tsx +++ b/client/src/components/DeviceComponents/DeviceQuickAction/DeviceQuickActionDropDown.tsx @@ -1,8 +1,10 @@ import { TerminalStateProps } from '@/components/TerminalModal'; import { DownOutlined } from '@ant-design/icons'; +import { history } from '@umijs/max'; import { Dropdown, MenuProps, Space } from 'antd'; import React, { Dispatch, ReactNode, SetStateAction } from 'react'; import DeviceQuickActionReference, { + Actions, Types, } from '@/components/DeviceComponents/DeviceQuickAction/DeviceQuickActionReference'; import PlaybookSelectionModal from '@/components/PlaybookSelection/PlaybookSelectionModal'; @@ -13,7 +15,7 @@ export type QuickActionProps = { onDropDownClicked: any; advancedMenu?: boolean; setTerminal: Dispatch>; - target?: API.DeviceItem[]; + target?: API.DeviceItem; children?: ReactNode; }; @@ -28,19 +30,25 @@ const DeviceQuickActionDropDown: React.FC = (props) => { alert('Internal Error'); return; } + if (DeviceQuickActionReference[idx].action === Actions.CONNECT) { + history.push({ + pathname: `/manage/devices/ssh/${props.target?.uuid}`, + }); + return; + } if (DeviceQuickActionReference[idx].type === Types.PLAYBOOK) { props.setTerminal({ isOpen: true, quickRef: DeviceQuickActionReference[idx].playbookQuickRef, - target: props.target, + target: props.target ? [props.target] : undefined, }); - } else if ( - DeviceQuickActionReference[idx].type === Types.PLAYBOOK_SELECTION - ) { + return; + } + if (DeviceQuickActionReference[idx].type === Types.PLAYBOOK_SELECTION) { setPlaybookSelectionModalIsOpened(true); - } else { - props.onDropDownClicked(idx); + return; } + props.onDropDownClicked(idx); } }; @@ -75,7 +83,7 @@ const DeviceQuickActionDropDown: React.FC = (props) => { props.setTerminal({ isOpen: true, command: playbook, - target: props.target, + target: props.target ? [props.target] : undefined, extraVars: extraVars, playbookName: playbookName, }); @@ -86,7 +94,7 @@ const DeviceQuickActionDropDown: React.FC = (props) => { diff --git a/client/src/components/HeaderComponents/AvatarDropdown.tsx b/client/src/components/HeaderComponents/AvatarDropdown.tsx index 8fc7eed7..0752c2e6 100644 --- a/client/src/components/HeaderComponents/AvatarDropdown.tsx +++ b/client/src/components/HeaderComponents/AvatarDropdown.tsx @@ -79,7 +79,11 @@ export const AvatarDropdown: React.FC = ({ const { key } = event; if (key === 'logout') { flushSync(() => { - setInitialState((s: any) => ({ ...s, currentUser: undefined })); + setInitialState((s: any) => ({ + ...s, + currentUser: undefined, + token: undefined, + })); }); loginOut(); return; diff --git a/client/src/components/Icons/CustomIcons.tsx b/client/src/components/Icons/CustomIcons.tsx index 4a8d603a..4724cc69 100644 --- a/client/src/components/Icons/CustomIcons.tsx +++ b/client/src/components/Icons/CustomIcons.tsx @@ -1249,3 +1249,22 @@ const MoreSvg = (props: any) => ( export const More = (props: Partial) => ( ); + +const Live24FilledSvg = (props: any) => ( + + + +); + +export const Live24Filled = (props: Partial) => ( + +); diff --git a/client/src/components/LiveLogs/LiveLogs.tsx b/client/src/components/LiveLogs/LiveLogs.tsx new file mode 100644 index 00000000..af2a3c84 --- /dev/null +++ b/client/src/components/LiveLogs/LiveLogs.tsx @@ -0,0 +1,95 @@ +import TerminalCore, { + TerminalCoreHandles, +} from '@/components/Terminal/TerminalCore'; +import { socket } from '@/socket'; +import { useParams } from '@@/exports'; +import { LoadingOutlined } from '@ant-design/icons'; +import { message } from 'antd'; +import React, { + RefObject, + useEffect, + useImperativeHandle, + useState, +} from 'react'; + +export interface LiveLogsHandles { + handleStop: () => void; + resetTerminalContent: () => void; +} + +export interface LiveLogsProps { + from: number; +} + +const LiveLogs = React.forwardRef( + ({ from }, ref) => { + const rows = Math.ceil(document.body.clientHeight / 16); + const cols = Math.ceil(document.body.clientWidth / 8); + const { id } = useParams(); + const terminalRef: RefObject = + React.createRef(); + + const onNewLogs = (value: any) => { + if (value) { + terminalRef?.current?.onDataIn(value.data); + } + }; + + const handleStop = () => { + socket.off('logs:newLogs', onNewLogs); + socket.disconnect(); + }; + + const resetTerminalContent = () => { + terminalRef?.current?.resetTerminalContent(); + }; + + useImperativeHandle(ref, () => ({ + handleStop, + resetTerminalContent, + })); + + useEffect(() => { + socket.connect(); + resetTerminalContent(); + socket + .emitWithAck('logs:getLogs', { containerId: id, from: from }) + .then((e) => { + if (e.status === 'OK') { + socket.on('logs:newLogs', onNewLogs); + } else { + void message.error({ + content: `Socket failed to connect (${e.status} - ${e.error})`, + duration: 6, + }); + } + }) + .catch((e) => { + void message.error({ + content: `Socket failed to connect ${e.message}`, + duration: 6, + }); + }); + + return () => { + socket.emit('logs:closing'); + socket.off('logs:newLogs', onNewLogs); + socket.disconnect(); + }; + }, [from, id]); + + return ( +
+ +
+ ); + }, +); + +export default LiveLogs; diff --git a/client/src/components/LiveLogs/LiveLogsToolbar.tsx b/client/src/components/LiveLogs/LiveLogsToolbar.tsx new file mode 100644 index 00000000..72484ad5 --- /dev/null +++ b/client/src/components/LiveLogs/LiveLogsToolbar.tsx @@ -0,0 +1,39 @@ +import { PauseCircleFilled, StopFilled, StopOutlined } from '@ant-design/icons'; +import { Button, DatePicker, DatePickerProps, Flex } from 'antd'; +import dayjs from 'dayjs'; +import React from 'react'; + +type LiveLogsToolbarProps = { + onStop: () => void; + fromDate: number; + setFromDate: any; +}; + +const LiveLogsToolbar: React.FC = ({ + onStop, + setFromDate, + fromDate, +}) => { + const onOk = (value: DatePickerProps['value']) => { + setFromDate(value?.unix()); + }; + return ( + + { + console.log('Selected Time: ', value); + console.log('Formatted Selected Time: ', dateString); + }} + onOk={onOk} + /> + + + ); +}; + +export default LiveLogsToolbar; diff --git a/client/src/components/ServiceComponents/ServiceQuickAction/ServiceQuickActionReference.tsx b/client/src/components/ServiceComponents/ServiceQuickAction/ServiceQuickActionReference.tsx index 7176fda7..df914834 100644 --- a/client/src/components/ServiceComponents/ServiceQuickAction/ServiceQuickActionReference.tsx +++ b/client/src/components/ServiceComponents/ServiceQuickAction/ServiceQuickActionReference.tsx @@ -1,3 +1,4 @@ +import { Live24Filled } from '@/components/Icons/CustomIcons'; import { CloseCircleOutlined, EditOutlined, @@ -25,9 +26,19 @@ export type ServiceQuickActionReferenceType = { export enum ServiceQuickActionReferenceActions { RENAME = 'rename', + LIVE_LOGS = 'logs', } const ServiceQuickActionReference: ServiceQuickActionReferenceType[] = [ + { + type: ServiceQuickActionReferenceTypes.ACTION, + action: ServiceQuickActionReferenceActions.LIVE_LOGS, + label: ( + <> + Live Logs + + ), + }, { type: ServiceQuickActionReferenceTypes.ACTION, action: ServiceQuickActionReferenceActions.RENAME, diff --git a/client/src/components/Template/Title.tsx b/client/src/components/Template/Title.tsx index d23108b4..ee5eaede 100644 --- a/client/src/components/Template/Title.tsx +++ b/client/src/components/Template/Title.tsx @@ -8,6 +8,7 @@ export enum PageContainerTitleColors { SETTINGS = '#266ea8', DEVICES = '#5e9a35', PLAYBOOKS = '#554dce', + CONTAINER_LOGS = '#4942ae', } export enum SettingsSubTitleColors { diff --git a/client/src/components/Terminal/TerminalCore.tsx b/client/src/components/Terminal/TerminalCore.tsx new file mode 100644 index 00000000..aa984476 --- /dev/null +++ b/client/src/components/Terminal/TerminalCore.tsx @@ -0,0 +1,103 @@ +import { FitAddon } from '@xterm/addon-fit'; +import React, { + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useState, +} from 'react'; +import { ITerminalOptions, Terminal } from 'xterm'; +import 'xterm/css/xterm.css'; + +export interface TerminalCoreHandles { + onDataIn: (value: string, newLine?: boolean) => void; + resetTerminalContent: () => void; +} + +export type TerminalCoreProps = { + onDataOut?: (value: string) => void; + disableStdin?: boolean; + rows?: number; + cols?: number; + convertEol?: boolean; + onResize?: (rows: number, cols: number) => void; +}; + +const TerminalCore = React.forwardRef( + ( + { + onDataOut, + disableStdin = false, + convertEol, + rows = Math.ceil(document.body.clientHeight / 16), + cols = Math.ceil(document.body.clientWidth / 8), + onResize, + }, + ref, + ) => { + const terminalProps: ITerminalOptions = { + disableStdin: disableStdin, + cursorStyle: 'underline', + cursorBlink: true, + allowTransparency: true, + fontSize: 12, + fontFamily: 'Menlo, Monaco, Consolas, monospace', + convertEol: convertEol, + }; + + const terminal = useMemo( + () => new Terminal({ ...terminalProps, rows: rows, cols: cols }), + [], + ); + const [terminalElement, setTerminalElement] = + useState(null); + const terminalRef = useCallback( + (node: React.SetStateAction) => + setTerminalElement(node), + [], + ); + const fitAddon = new FitAddon(); + + function resizeScreen() { + if (terminal) { + fitAddon.fit(); + onResize?.(terminal.rows, terminal.cols); + } + } + + window.addEventListener('resize', resizeScreen, false); + + const onDataIn = (value: string, newLine?: boolean) => { + if (newLine) { + terminal?.writeln(value); + } else { + terminal?.write(value); + } + }; + + const resetTerminalContent = () => { + terminal?.clear(); + }; + + useImperativeHandle(ref, () => ({ + onDataIn, + resetTerminalContent, + })); + + useEffect(() => { + if (terminalElement && !terminal.element) { + terminal.open(terminalElement); + terminal.loadAddon(fitAddon); + if (onDataOut) { + terminal.onData(onDataOut); + } + fitAddon.fit(); + terminal.focus(); + } + }, [terminal, terminalElement]); + + return
; + }, +); + +export default TerminalCore; diff --git a/client/src/components/TerminalModal/TerminalCoreModal.tsx b/client/src/components/TerminalModal/TerminalCoreModal.tsx index 150ff499..26f04b11 100644 --- a/client/src/components/TerminalModal/TerminalCoreModal.tsx +++ b/client/src/components/TerminalModal/TerminalCoreModal.tsx @@ -1,12 +1,22 @@ +import TerminalCore, { + TerminalCoreHandles, +} from '@/components/Terminal/TerminalCore'; import TerminalHandler, { TaskStatusTimelineType, } from '@/components/TerminalModal/TerminalHandler'; -import TerminalLogs from '@/components/TerminalModal/TerminalLogs'; import { ClockCircleOutlined, ThunderboltOutlined } from '@ant-design/icons'; -import { DotLottiePlayer } from '@dotlottie/react-player'; +import { + DotLottieCommonPlayer, + DotLottiePlayer, +} from '@dotlottie/react-player'; import { Button, Col, Modal, Row, Steps } from 'antd'; -import React, { useEffect, useImperativeHandle, useRef, useState } from 'react'; -import { ReactTerminal, TerminalContext } from 'react-terminal'; +import React, { + LegacyRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react'; import { API } from 'ssm-shared-lib'; export interface TerminalCoreModalHandles { @@ -30,11 +40,6 @@ const modalStyles = { }, }; -const terminalContentStyle = { - fontSize: '11px', - fontFamily: 'Menlo', -}; - const POLLING_INTERVAL_MS = 3000; const TerminalCoreModal = React.forwardRef< @@ -63,24 +68,24 @@ const TerminalCoreModal = React.forwardRef< const [savedStatuses, setSavedStatuses] = useState(statusesType); const timerIdRef = useRef(); const [hasReachedFinalStatus, setHasReachedFinalStatus] = useState(false); - const { setBufferedContent } = React.useContext(TerminalContext); + const terminalRef = useRef(null); + const lottieRef = useRef(); + + useEffect(() => { + if (hasReachedFinalStatus) { + terminalRef?.current?.onDataIn('# Playbook execution finished', true); + lottieRef.current?.stop(); + } + }, [hasReachedFinalStatus]); + const execLogCallBack = (execLog: API.ExecLog) => { - setBufferedContent((previous) => ( - - )); + if (execLog.stdout) { + terminalRef?.current?.onDataIn(execLog.stdout as string, true); + } }; const resetScreen = () => { - setBufferedContent(() => ( - <> - Starting... -
- - )); + terminalRef?.current?.resetTerminalContent(); }; const terminalHandler = new TerminalHandler( @@ -131,7 +136,7 @@ const TerminalCoreModal = React.forwardRef< }, [isPollingEnabled]); const resetTerminal = () => { - setBufferedContent(''); + resetScreen(); setIsPollingEnabled(false); setSavedStatuses([taskInit]); terminalHandler.resetTerminal(); @@ -159,6 +164,7 @@ const TerminalCoreModal = React.forwardRef< title={
} src="/Animation-1705922266332.lottie" autoplay loop @@ -198,12 +204,12 @@ const TerminalCoreModal = React.forwardRef<
-
diff --git a/client/src/global.tsx b/client/src/global.tsx index cdac7873..fe64c0f3 100644 --- a/client/src/global.tsx +++ b/client/src/global.tsx @@ -1,8 +1,12 @@ import '@umijs/max'; import { Button, message, notification } from 'antd'; +import { useState } from 'react'; import defaultSettings from '../config/defaultSettings'; +import { socket } from './socket'; + const { pwa } = defaultSettings; const isHttps = document.location.protocol === 'https:'; + const clearCache = () => { // remove all caches if (window.caches) { diff --git a/client/src/pages/Admin/Inventory/InventoryColumns.tsx b/client/src/pages/Admin/Inventory/InventoryColumns.tsx index 6c9df200..d260e7d4 100644 --- a/client/src/pages/Admin/Inventory/InventoryColumns.tsx +++ b/client/src/pages/Admin/Inventory/InventoryColumns.tsx @@ -202,7 +202,7 @@ const InventoryColumns = ( advancedMenu={true} onDropDownClicked={onDropDownClicked} setTerminal={setTerminal} - target={[record]} + target={record} /> , ], diff --git a/client/src/pages/Admin/Inventory/index.tsx b/client/src/pages/Admin/Inventory/index.tsx index fa8b3064..711b606d 100644 --- a/client/src/pages/Admin/Inventory/index.tsx +++ b/client/src/pages/Admin/Inventory/index.tsx @@ -38,6 +38,7 @@ import OsSoftwareVersions from '@/components/DeviceComponents/OSSoftwaresVersion import NewUnManagedDeviceModal from '@/components/NewDeviceModal/NewUnManagedDeviceModal'; import { API } from 'ssm-shared-lib'; import { DatabaseOutlined, WarningOutlined } from '@ant-design/icons'; +import { history } from '@umijs/max'; const Inventory: React.FC = () => { const { id } = useParams(); @@ -82,9 +83,6 @@ const Inventory: React.FC = () => { const onDropDownClicked = (idx: number) => { if (DeviceQuickActionReference[idx].type === Types.ACTION) { - if (DeviceQuickActionReference[idx].action === Actions.CONNECT) { - window.location.href = 'ssh://' + currentRow?.ip; - } if (DeviceQuickActionReference[idx].action === Actions.DELETE) { setShowConfirmDeleteDevice(true); } diff --git a/client/src/pages/Admin/Logs/index.tsx b/client/src/pages/Admin/Logs/index.tsx index e9c1b09e..20ad7f59 100644 --- a/client/src/pages/Admin/Logs/index.tsx +++ b/client/src/pages/Admin/Logs/index.tsx @@ -1,7 +1,6 @@ import Title, { PageContainerTitleColors } from '@/components/Template/Title'; import ServerLogsColumns from '@/pages/Admin/Logs/ServerLogsColums'; import { getServerLogs, getTasksLogs } from '@/services/rest/logs'; -import { getQueryStringParams } from '@/utils/querystring'; import { UnorderedListOutlined } from '@ant-design/icons'; import { ColumnsState, @@ -12,12 +11,12 @@ import { ProForm } from '@ant-design/pro-form/lib'; import React, { useState } from 'react'; import TaskLogsColumns from '@/pages/Admin/Logs/TaskLogsColumns'; import { API } from 'ssm-shared-lib'; -import { useLocation } from '@umijs/max'; +import { useSearchParams } from '@umijs/max'; const Index: React.FC = () => { const [form] = ProForm.useForm(); - const { search } = useLocation(); - const query = getQueryStringParams(search); + const [searchParams] = useSearchParams(); + const [columnsStateMap, setColumnsStateMap] = useState< Record >({ @@ -28,11 +27,11 @@ const Index: React.FC = () => { show: false, }, }); - if (query.module) { - form.setFieldsValue({ module: query.module }); + if (searchParams.get('module')) { + form.setFieldsValue({ module: searchParams.get('module') }); } - if (query.moduleId) { - form.setFieldsValue({ moduleId: query.moduleId }); + if (searchParams.get('moduleId')) { + form.setFieldsValue({ moduleId: searchParams.get('moduleId') }); } const logsTabItem = [ { diff --git a/client/src/pages/Devices/DeviceSSHTerminal.tsx b/client/src/pages/Devices/DeviceSSHTerminal.tsx new file mode 100644 index 00000000..565e54a3 --- /dev/null +++ b/client/src/pages/Devices/DeviceSSHTerminal.tsx @@ -0,0 +1,93 @@ +import { Live24Filled } from '@/components/Icons/CustomIcons'; +import Title, { PageContainerTitleColors } from '@/components/Template/Title'; +import TerminalCore, { + TerminalCoreHandles, +} from '@/components/Terminal/TerminalCore'; +import { socket } from '@/socket'; +import { useParams } from '@@/exports'; +import { PageContainer } from '@ant-design/pro-components'; +import { message } from 'antd'; +import React, { memo, RefObject, useEffect, useState } from 'react'; + +const DeviceSSHTerminal = () => { + const { id } = useParams(); + const ref: RefObject = + React.createRef(); + + const rows = Math.ceil(document.body.clientHeight / 16); + const cols = Math.ceil(document.body.clientWidth / 8); + + const onDataOut = (value: string) => { + socket.emit('ssh:data', value); + }; + + const onResize = (newRows: number, newCols: number) => { + socket.emit('ssh:resize', { cols: newCols, rows: newRows }); + }; + + useEffect(() => { + if (ref.current) { + socket.connect(); + const onDataIn = ref.current?.onDataIn; + socket + .emitWithAck('ssh:start', { + deviceUuid: id, + rows: rows, + cols: cols, + }) + .then((e) => { + if (e.status !== 'OK') { + void message.error({ + content: `Socket failed to connect (${e.status} - ${e.error})`, + duration: 6, + }); + } else { + socket.on('ssh:data', onDataIn); + socket.on('ssh:status', (value) => { + message.info({ + content: `${value.status} - ${value.message}`, + duration: 6, + }); + if (value.status !== 'OK') { + onDataIn('Error'); + } + }); + } + }) + .catch((e) => { + void message.error({ + content: `Socket failed to connect ${e.message}`, + duration: 6, + }); + }); + + return () => { + socket.emit('logs:closing'); + socket.off('ssh:data', onDataIn); + socket.disconnect(); + }; + } + }, [id, ref.current]); + + return ( + } + /> + } + > + + + ); +}; + +export default DeviceSSHTerminal; diff --git a/client/src/pages/Devices/index.tsx b/client/src/pages/Devices/index.tsx index 009d85d4..689240a9 100644 --- a/client/src/pages/Devices/index.tsx +++ b/client/src/pages/Devices/index.tsx @@ -121,7 +121,7 @@ const Index = memo(() => { , ]} diff --git a/client/src/pages/Services/components/Containers.tsx b/client/src/pages/Services/components/Containers.tsx index 0bb44694..27093139 100644 --- a/client/src/pages/Services/components/Containers.tsx +++ b/client/src/pages/Services/components/Containers.tsx @@ -1,13 +1,12 @@ -import ContainerMetas from '@/pages/Devices/ContainerMetas'; +import ContainerMetas from '@/pages/Services/components/containers/ContainerMetas'; import EditContainerNameModal from '@/pages/Services/components/containers/EditContainerNameModal'; import { getContainers, postRefreshAll } from '@/services/rest/containers'; -import { getQueryStringParams } from '@/utils/querystring'; import { ReloadOutlined } from '@ant-design/icons'; import { ActionType, ProList } from '@ant-design/pro-components'; -import { useLocation } from '@umijs/max'; import { Button, Form } from 'antd'; import React, { useEffect, useRef, useState } from 'react'; import { API } from 'ssm-shared-lib'; +import { useSearchParams } from '@umijs/max'; const Containers: React.FC = () => { const actionRef = useRef(); @@ -20,17 +19,17 @@ const Containers: React.FC = () => { >(); const [form] = Form.useForm(); const [refreshAllIsLoading, setRefreshAllIsLoading] = useState(false); - const { search } = useLocation(); - const query = getQueryStringParams(search); + const [searchParams] = useSearchParams(); + const searchDeviceUuid = searchParams.get('deviceUuid'); useEffect(() => { - if (query.deviceUuid) { + if (searchDeviceUuid) { form.setFieldsValue({ - deviceUuid: query.deviceUuid, + deviceUuid: searchDeviceUuid, }); form.submit(); } - }, [query.deviceUuid]); + }, [searchDeviceUuid]); const handleRefreshAll = () => { setRefreshAllIsLoading(true); diff --git a/client/src/pages/Devices/ContainerMetas.tsx b/client/src/pages/Services/components/containers/ContainerMetas.tsx similarity index 95% rename from client/src/pages/Devices/ContainerMetas.tsx rename to client/src/pages/Services/components/containers/ContainerMetas.tsx index c5be4031..b64b78cc 100644 --- a/client/src/pages/Devices/ContainerMetas.tsx +++ b/client/src/pages/Services/components/containers/ContainerMetas.tsx @@ -20,6 +20,7 @@ import { import { Flex, message, Popover, Tag, Tooltip, Typography } from 'antd'; import React from 'react'; import { API, SsmContainer } from 'ssm-shared-lib'; +import { history } from '@umijs/max'; type ContainerMetasProps = { selectedRecord?: API.Container; @@ -42,6 +43,14 @@ const ContainerMetas = (props: ContainerMetasProps) => { ) { props.setIsEditContainerCustomNameModalOpened(true); } + if ( + ServiceQuickActionReference[idx].action === + ServiceQuickActionReferenceActions.LIVE_LOGS + ) { + history.push({ + pathname: `/manage/services/logs/${props.selectedRecord?.id}`, + }); + } if ( Object.values(SsmContainer.Actions).includes( ServiceQuickActionReference[idx].action as SsmContainer.Actions, diff --git a/client/src/pages/Services/index.tsx b/client/src/pages/Services/index.tsx index dbb73f0b..9a4239c7 100644 --- a/client/src/pages/Services/index.tsx +++ b/client/src/pages/Services/index.tsx @@ -40,7 +40,7 @@ const Index: React.FC = () => { children: [ { icon: , - label: 'Templates', + label: 'Store', key: 'templates', }, { diff --git a/client/src/pages/Services/logs/Logs.tsx b/client/src/pages/Services/logs/Logs.tsx new file mode 100644 index 00000000..c807b982 --- /dev/null +++ b/client/src/pages/Services/logs/Logs.tsx @@ -0,0 +1,40 @@ +import { Live24Filled } from '@/components/Icons/CustomIcons'; +import LiveLogs, { LiveLogsHandles } from '@/components/LiveLogs/LiveLogs'; +import LiveLogsToolbar from '@/components/LiveLogs/LiveLogsToolbar'; +import Title, { PageContainerTitleColors } from '@/components/Template/Title'; +import { PageContainer } from '@ant-design/pro-components'; +import React, { RefObject, useState } from 'react'; +import { TerminalContextProvider } from 'react-terminal'; +import moment from 'moment'; + +const Logs: React.FC = () => { + const ref: RefObject = React.createRef(); + const [fromDate, setFromDate] = useState(moment().unix()); + + const onClickStop = () => { + ref.current?.handleStop(); + }; + + return ( + + } + /> + } + > + + + + + ); +}; + +export default Logs; diff --git a/client/src/pages/User/Login/index.tsx b/client/src/pages/User/Login/index.tsx index e8fdefd3..d370a7af 100644 --- a/client/src/pages/User/Login/index.tsx +++ b/client/src/pages/User/Login/index.tsx @@ -24,6 +24,7 @@ const Login: React.FC = () => { setInitialState((s: any) => ({ ...s, currentUser: userInfo, + token: token, })); }); } diff --git a/client/src/requestErrorConfig.ts b/client/src/requestErrorConfig.ts index ca15b8ba..26faf8fe 100644 --- a/client/src/requestErrorConfig.ts +++ b/client/src/requestErrorConfig.ts @@ -1,5 +1,4 @@ -import type { RequestOptions } from '@@/plugin-request/request'; -import type { RequestConfig } from '@umijs/max'; +import type { RequestConfig } from '@umijs/max'; import { message, notification } from 'antd'; enum ErrorShowType { diff --git a/client/src/services/rest/user.ts b/client/src/services/rest/user.ts index 0bf565f9..bb975e93 100644 --- a/client/src/services/rest/user.ts +++ b/client/src/services/rest/user.ts @@ -25,7 +25,7 @@ export async function outLogin(options?: { [key: string]: any }) { } export async function currentUser(options?: Record) { - return request('/api/users/current', { + return request>('/api/users/current', { method: 'GET', ...(options || {}), }); diff --git a/client/src/socket.ts b/client/src/socket.ts new file mode 100644 index 00000000..d86a8811 --- /dev/null +++ b/client/src/socket.ts @@ -0,0 +1,6 @@ +import { io } from 'socket.io-client'; + +export const socket = io({ + path: '/api/socket.io/', + autoConnect: false, +}); diff --git a/client/src/utils/querystring.ts b/client/src/utils/querystring.ts deleted file mode 100644 index 8cb9aa27..00000000 --- a/client/src/utils/querystring.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const getQueryStringParams = (query: string): any => { - console.log(query); - return query - ? (/^[?#]/.test(query) ? query.slice(1) : query) - .split('&') - .reduce((params, param) => { - const [key, value] = param.split('='); - (params as any)[key] = value - ? decodeURIComponent(value.replace(/\+/g, ' ')) - : ''; - return params; - }, {}) - : {}; -}; diff --git a/proxy/default.conf b/proxy/default.conf index cbae525b..22739a88 100644 --- a/proxy/default.conf +++ b/proxy/default.conf @@ -5,6 +5,17 @@ server { access_log off; error_log off; + location /api/socket.io/ { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + + proxy_pass http://server:3000/socket.io/; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + location /api/ { proxy_pass http://server:3000/; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/server/package-lock.json b/server/package-lock.json index 27342b64..3a688d5c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -44,6 +44,7 @@ "rewire": "^7.0.0", "semver": "^7.6.3", "shelljs": "^0.8.5", + "socket.io": "^4.7.5", "ssm-shared-lib": "file:../shared-lib/", "yaml": "^2.5.0" }, @@ -69,6 +70,7 @@ "@types/passport-http-bearer": "^1.0.41", "@types/passport-jwt": "^4.0.1", "@types/shelljs": "^0.8.15", + "@types/ssh2": "^1.15.1", "@types/supertest": "^6.0.2", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.0.1", @@ -2731,6 +2733,12 @@ "node": ">=16.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@stylistic/eslint-plugin": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-2.6.1.tgz", @@ -2923,6 +2931,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", + "license": "MIT" + }, "node_modules/@types/cookie-parser": { "version": "1.4.7", "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.7.tgz", @@ -2953,6 +2967,15 @@ "@types/node": "*" } }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/docker-modem": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz", @@ -3298,9 +3321,9 @@ } }, "node_modules/@types/ssh2": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.0.tgz", - "integrity": "sha512-YcT8jP5F8NzWeevWvcyrrLB3zcneVjzYY9ZDSMAMboI+2zR1qYWFhwsyOFVzT7Jorn67vqxC0FRiw8YyG9P1ww==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.1.tgz", + "integrity": "sha512-ZIbEqKAsi5gj35y4P4vkJYly642wIbY6PqoN0xiyQGshKUGXR9WQjF/iF9mXBQ8uBKy3ezfsCkcoHKhd0BzuDA==", "dev": true, "license": "MIT", "dependencies": { @@ -3959,6 +3982,15 @@ ], "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/bcrypt": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", @@ -4409,6 +4441,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/cpu-features": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", @@ -4760,6 +4805,36 @@ "once": "^1.4.0" } }, + "node_modules/engine.io": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", + "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", + "license": "MIT", + "dependencies": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/es-define-property": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", @@ -8826,6 +8901,47 @@ "npm": ">= 3.0.0" } }, + "node_modules/socket.io": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.5.tgz", + "integrity": "sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.5.2", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "license": "MIT", + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/socks": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", @@ -9721,6 +9837,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/server/package.json b/server/package.json index 4ccf6cba..07f68cb0 100644 --- a/server/package.json +++ b/server/package.json @@ -49,7 +49,8 @@ "semver": "^7.6.3", "shelljs": "^0.8.5", "ssm-shared-lib": "file:../shared-lib/", - "yaml": "^2.5.0" + "yaml": "^2.5.0", + "socket.io": "^4.7.5" }, "overrides": { "minimatch": "5.1.2", @@ -75,6 +76,7 @@ "@types/passport-http": "^0.3.11", "@types/passport-http-bearer": "^1.0.41", "@types/passport-jwt": "^4.0.1", + "@types/ssh2": "^1.15.1", "@types/shelljs": "^0.8.15", "@types/supertest": "^6.0.2", "@types/uuid": "^10.0.0", diff --git a/server/src/App.ts b/server/src/App.ts index 9c6e6c6f..0ce92a6e 100644 --- a/server/src/App.ts +++ b/server/src/App.ts @@ -3,14 +3,17 @@ import cookieParser from 'cookie-parser'; import express from 'express'; import passport from 'passport'; import pinoHttp from 'pino-http'; +import { Server } from 'socket.io'; import { SECRET } from './config'; import logger, { httpLoggerOptions } from './logger'; import { errorHandler } from './middlewares/ErrorHandler'; +import Socket from './middlewares/Socket'; import routes from './routes'; class AppWrapper { protected readonly app = express(); private server?: http.Server; + private socket!: Socket; constructor() { this.setup(); @@ -42,6 +45,7 @@ class AppWrapper { 🐿 Squirrel Servers Manager 🚀 Server ready at: http://localhost:3000`), ); + this.socket = new Socket(this.server); } public stopServer(callback: () => any) { @@ -55,6 +59,10 @@ class AppWrapper { public getExpressApp() { return this.app; } + + public getSocket() { + return this.socket; + } } export default new AppWrapper(); diff --git a/server/src/helpers/ssh/SSHCredentialsHelper.ts b/server/src/helpers/ssh/SSHCredentialsHelper.ts new file mode 100644 index 00000000..56d8c2ba --- /dev/null +++ b/server/src/helpers/ssh/SSHCredentialsHelper.ts @@ -0,0 +1,91 @@ +import Dockerode from 'dockerode'; +import { Config } from 'node-ssh'; +import { ConnectConfig } from 'ssh2'; +import { SSHType } from 'ssm-shared-lib/distribution/enums/ansible'; +import Device from '../../data/database/model/Device'; +import DeviceAuth from '../../data/database/model/DeviceAuth'; +import { DEFAULT_VAULT_ID, vaultDecrypt } from '../../modules/ansible-vault/ansible-vault'; + +class SSHCredentialsHelper { + async getSShConnection(device: Device, deviceAuth: DeviceAuth) { + const baseSsh: ConnectConfig = { + tryKeyboard: true, // deviceAuth.customDockerTryKeyboard, + forceIPv4: deviceAuth.customDockerForcev4, + forceIPv6: deviceAuth.customDockerForcev6, + host: device.ip, + port: deviceAuth.sshPort, + }; + const sshCredentials: ConnectConfig = await this.handleSSHCredentials(deviceAuth); + return { ...baseSsh, ...sshCredentials }; + } + + async getDockerSshConnectionOptions(device: Device, deviceAuth: DeviceAuth) { + const baseSsh: ConnectConfig = { + tryKeyboard: true, // deviceAuth.customDockerTryKeyboard, + forceIPv4: deviceAuth.customDockerForcev4, + forceIPv6: deviceAuth.customDockerForcev6, + host: device.ip, + port: deviceAuth.sshPort, + }; + + const sshCredentials: ConnectConfig = deviceAuth.customDockerSSH + ? await this.handleCustomSSHCredentials(deviceAuth) + : await this.handleSSHCredentials(deviceAuth); + + const options: Dockerode.DockerOptions & { modem?: any; _deviceUuid?: string } = { + _deviceUuid: device.uuid, + protocol: 'ssh', + port: deviceAuth.sshPort, + username: deviceAuth.sshUser, //TODO: If device change ip, reset watchers + host: device.ip, + sshOptions: { ...baseSsh, ...sshCredentials }, + }; + + return options; + } + + private async handleSSHCredentials(deviceAuth: DeviceAuth): Promise { + return this.determineSSHCredentials( + deviceAuth.authType as SSHType, + deviceAuth.sshUser as string, + deviceAuth.sshPwd, + deviceAuth.sshKey, + deviceAuth.sshKeyPass, + ); + } + + private async handleCustomSSHCredentials(deviceAuth: DeviceAuth): Promise { + return this.determineSSHCredentials( + deviceAuth.dockerCustomAuthType as SSHType, + deviceAuth.dockerCustomSshUser as string, + deviceAuth.dockerCustomSshPwd, + deviceAuth.dockerCustomSshKey, + deviceAuth.dockerCustomSshKeyPass, + ); + } + + private async determineSSHCredentials( + authType: SSHType, + sshUsername: string, + sshPwd?: string, + sshKey?: string, + sshKeyPass?: string, + ): Promise { + let sshCredentials: ConnectConfig = {}; + if (authType === SSHType.KeyBased) { + sshCredentials = { + username: sshUsername, + privateKey: sshKey ? await vaultDecrypt(sshKey, DEFAULT_VAULT_ID) : undefined, + passphrase: sshKeyPass ? await vaultDecrypt(sshKeyPass, DEFAULT_VAULT_ID) : undefined, + }; + } else if (authType === SSHType.UserPassword) { + sshCredentials = { + username: sshUsername, + password: sshPwd ? await vaultDecrypt(sshPwd, DEFAULT_VAULT_ID) : undefined, + }; + } + return sshCredentials; + } +} + +export default new SSHCredentialsHelper(); diff --git a/server/src/middlewares/Passport.ts b/server/src/middlewares/Passport.ts index 83e01a8e..276b3e82 100644 --- a/server/src/middlewares/Passport.ts +++ b/server/src/middlewares/Passport.ts @@ -8,7 +8,7 @@ import UserRepo from '../data/database/repository/UserRepo'; const JWTStrategy = passportJWT.Strategy; const BearerStrategy = passportBearer.Strategy; -const cookieExtractor = (req: Request) => { +export const cookieExtractor = (req: Request) => { let jwt = null; if (req && req.cookies) { jwt = req.cookies['jwt']; diff --git a/server/src/middlewares/Socket.ts b/server/src/middlewares/Socket.ts new file mode 100644 index 00000000..214072a0 --- /dev/null +++ b/server/src/middlewares/Socket.ts @@ -0,0 +1,80 @@ +import http from 'http'; +import { DefaultEventsMap } from '@socket.io/component-emitter'; +import { NextFunction, Request, Response } from 'express'; +import pino from 'pino'; +import { Server, Socket as _Socket } from 'socket.io'; +import * as jwt from 'jsonwebtoken'; +import { parse } from 'cookie'; +import { SECRET } from '../config'; +import UserRepo from '../data/database/repository/UserRepo'; +import _logger from '../logger'; +import { getContainerLogs } from '../services/socket/container-logs'; +import { startSSHSession } from '../services/socket/ssh-session'; + +export type SSMSocket = _Socket; +export type SSMSocketServer = Server; + +export default class Socket { + private readonly io!: Server; + private logger: pino.Logger; + + constructor(server: http.Server) { + this.logger = _logger.child( + { + module: `Socket`, + }, + { msgPrefix: '[SOCKET] - ' }, + ); + this.io = new Server(server); + this.setup(); + } + + private setup() { + this.logger.info('setting up socket'); + this.io.engine.use(this.socketJWT); + + this.io.on('connection', async (socket) => { + const io = this.io; + socket.on('logs:getLogs', getContainerLogs({ io, socket })); + socket.on('ssh:start', startSSHSession({ io, socket })); + }); + this.io.engine.on('connection_error', (err) => { + this.logger.debug(err.req); // the request object + this.logger.debug(err.code); // the error code, for example 1 + this.logger.debug(err.message); // the error message, for example "Session ID unknown" + this.logger.debug(err.context); // some additional error context + }); + } + + private socketJWT = (req: Request, res: Response, next: NextFunction) => { + // @ts-expect-error must complete the req type + const isHandshake = req._query.sid === undefined; + if (isHandshake) { + if (!req.headers?.cookie) { + next(); + } + const cookies = parse(req.headers.cookie as string); + if (!cookies) { + next(); + } + const jwtCookie = cookies['jwt']; + if (!jwtCookie) { + next(); + } + jwt.verify(jwtCookie, SECRET, (err, decoded) => { + if (err) { + return next(new Error('invalid token')); + } + // @ts-expect-error must complete the req type + UserRepo.findByEmail(decoded.email).then((user) => { + if (user) { + req.user = user; + } + }); + next(); + }); + } else { + next(); + } + }; +} diff --git a/server/src/modules/docker/core/DockerAPIHelper.ts b/server/src/modules/docker/core/DockerAPIHelper.ts deleted file mode 100644 index 11429188..00000000 --- a/server/src/modules/docker/core/DockerAPIHelper.ts +++ /dev/null @@ -1,83 +0,0 @@ -import Dockerode from 'dockerode'; -import { ConnectConfig } from 'ssh2'; -import { SsmAnsible } from 'ssm-shared-lib'; -import Device from '../../../data/database/model/Device'; -import DeviceAuth from '../../../data/database/model/DeviceAuth'; -import { DEFAULT_VAULT_ID, vaultDecrypt } from '../../ansible-vault/ansible-vault'; - -const SSHType = SsmAnsible.SSHType; - -function getDockerApiAuth() { - const auth = { - username: 'XXXX', - password: 'XXX', - email: 'XXXX', - serveraddress: 'https://index.docker.io/v1', - }; - return { - authconfig: auth, - }; -} - -async function getDockerSshConnectionOptions(device: Device, deviceAuth: DeviceAuth) { - const options: Dockerode.DockerOptions & { modem?: any; _deviceUuid?: string } = { - _deviceUuid: device.uuid, - protocol: 'ssh', - port: deviceAuth.sshPort, - username: deviceAuth.sshUser, //TODO: If device change ip, reset watchers - host: device.ip, - }; - - const baseSsh: ConnectConfig = { - tryKeyboard: true, //deviceAuth.customDockerTryKeyboard, - forceIPv4: deviceAuth.customDockerForcev4, - forceIPv6: deviceAuth.customDockerForcev6, - host: device.ip, - port: deviceAuth.sshPort, - }; - - let sshCredentials: ConnectConfig = {}; - if (deviceAuth.customDockerSSH) { - const sshUsername = deviceAuth.dockerCustomSshUser; - const sshPwd = deviceAuth.dockerCustomSshPwd; - const sshKey = deviceAuth.dockerCustomSshKey; - const sshKeyPass = deviceAuth.dockerCustomSshKeyPass; - if (deviceAuth.dockerCustomAuthType === SSHType.KeyBased) { - sshCredentials = { - username: sshUsername, - privateKey: sshKey ? await vaultDecrypt(sshKey, DEFAULT_VAULT_ID) : undefined, - passphrase: sshKeyPass ? await vaultDecrypt(sshKeyPass, DEFAULT_VAULT_ID) : undefined, - }; - } else if (deviceAuth.dockerCustomAuthType === SSHType.UserPassword) { - sshCredentials = { - username: sshUsername, - password: sshPwd ? await vaultDecrypt(sshPwd, DEFAULT_VAULT_ID) : undefined, - }; - } - } else { - const sshUsername = deviceAuth.sshUser; - const sshPwd = deviceAuth.sshPwd; - const sshKey = deviceAuth.sshKey; - const sshKeyPass = deviceAuth.sshKeyPass; - if (deviceAuth.authType === SSHType.KeyBased) { - sshCredentials = { - username: sshUsername, - privateKey: sshKey ? await vaultDecrypt(sshKey, DEFAULT_VAULT_ID) : undefined, - passphrase: sshKeyPass ? await vaultDecrypt(sshKeyPass, DEFAULT_VAULT_ID) : undefined, - }; - } else if (deviceAuth.authType === SSHType.UserPassword) { - sshCredentials = { - username: sshUsername, - password: sshPwd ? await vaultDecrypt(sshPwd, DEFAULT_VAULT_ID) : undefined, - }; - } - } - - options.sshOptions = { ...baseSsh, ...sshCredentials }; - return options; -} - -export default { - getDockerApiAuth, - getDockerSshConnectionOptions, -}; diff --git a/server/src/modules/docker/watchers/providers/docker/AbstractDockerLogs.ts b/server/src/modules/docker/watchers/providers/docker/AbstractDockerLogs.ts new file mode 100644 index 00000000..c6e97677 --- /dev/null +++ b/server/src/modules/docker/watchers/providers/docker/AbstractDockerLogs.ts @@ -0,0 +1,41 @@ +import stream from 'stream'; +import Dockerode from 'dockerode'; +import DockerImages from './AbstractDockerImages'; + +export default class DockerLogs extends DockerImages { + dockerApi: Dockerode | undefined = undefined; + + public getContainerLiveLogs(containerId: string, from: number, callback: (data: string) => void) { + const container = (this.dockerApi as Dockerode).getContainer(containerId); + if (container) { + const logStream = new stream.PassThrough(); + logStream.on('data', (chunk) => { + this.childLogger.debug(chunk.toString('utf8')); + callback(chunk.toString('utf8')); + }); + container.logs( + { stderr: true, stdout: true, follow: true, since: from, timestamps: true }, + (err, stream) => { + if (err) { + this.childLogger.error(err.message); + return; + } + if (stream) { + (this.dockerApi as Dockerode).modem.demuxStream(stream, logStream, logStream); + stream.on('end', () => { + this.childLogger.info(`Logs stream for container ${containerId} ended`); + logStream.end('!stop!'); + }); + } else { + throw new Error(`Stream is null for requested containerId ${containerId}`); + } + }, + ); + return () => { + logStream.end(); + }; + } else { + throw new Error(`Container not found for ${containerId}`); + } + } +} diff --git a/server/src/modules/docker/watchers/providers/docker/Docker.ts b/server/src/modules/docker/watchers/providers/docker/Docker.ts index 286618ed..07fb35cc 100644 --- a/server/src/modules/docker/watchers/providers/docker/Docker.ts +++ b/server/src/modules/docker/watchers/providers/docker/Docker.ts @@ -9,11 +9,11 @@ import ContainerRepo from '../../../../../data/database/repository/ContainerRepo import ContainerStatsRepo from '../../../../../data/database/repository/ContainerStatsRepo'; import DeviceAuthRepo from '../../../../../data/database/repository/DeviceAuthRepo'; import DeviceRepo from '../../../../../data/database/repository/DeviceRepo'; +import SSHCredentialsHelper from '../../../../../helpers/ssh/SSHCredentialsHelper'; import logger from '../../../../../logger'; import DeviceUseCases from '../../../../../use-cases/DeviceUseCases'; import Component from '../../../core/Component'; import { getCustomAgent } from '../../../core/CustomAgent'; -import DockerAPIHelper from '../../../core/DockerAPIHelper'; import { Label } from '../../../utils/label'; import tag from '../../../utils/tag'; import { @@ -27,7 +27,7 @@ import { normalizeContainer, pruneOldContainers, } from '../../../utils/utils'; -import DockerImages from './AbstractDockerImages'; +import DockerLogs from './AbstractDockerLogs'; // The delay before starting the watcher when the app is started const START_WATCHER_DELAY_MS = 1000; @@ -35,7 +35,7 @@ const START_WATCHER_DELAY_MS = 1000; // Debounce delay used when performing a watch after a docker event has been received const DEBOUNCED_WATCH_CRON_MS = 5000; -export default class Docker extends DockerImages { +export default class Docker extends DockerLogs { watchCron!: CronJob.ScheduledTask | undefined; watchCronStat!: CronJob.ScheduledTask | undefined; watchCronTimeout: any; @@ -123,7 +123,7 @@ export default class Docker extends DockerImages { if (!deviceAuth) { throw new Error(`DeviceAuth not found: ${this.configuration.deviceUuid}`); } - const options = await DockerAPIHelper.getDockerSshConnectionOptions(device, deviceAuth); + const options = await SSHCredentialsHelper.getDockerSshConnectionOptions(device, deviceAuth); this.childLogger.debug(options); const agent = getCustomAgent(this.childLogger, { debug: (message: any) => { diff --git a/server/src/modules/ssh/SSHConnectionInstance.ts b/server/src/modules/ssh/SSHConnectionInstance.ts new file mode 100644 index 00000000..9ee0d9e4 --- /dev/null +++ b/server/src/modules/ssh/SSHConnectionInstance.ts @@ -0,0 +1,33 @@ +import ssh from 'ssh2'; +import DeviceAuthRepo from '../../data/database/repository/DeviceAuthRepo'; +import DeviceRepo from '../../data/database/repository/DeviceRepo'; +import SSHCredentialsHelper from '../../helpers/ssh/SSHCredentialsHelper'; +import { NotFoundError } from '../../middlewares/api/ApiError'; + +export default class SSHConnectionInstance { + public ssh = new ssh.Client(); + private readonly deviceUuid: string; + + constructor(deviceUuid: string) { + this.deviceUuid = deviceUuid; + } + + async connect() { + const { device, deviceAuth } = await this.fetchDeviceAndAuth(); + const sshCredentials = await SSHCredentialsHelper.getSShConnection(device, deviceAuth); + + this.ssh.connect(sshCredentials); + } + + private async fetchDeviceAndAuth() { + const device = await DeviceRepo.findOneByUuid(this.deviceUuid); + if (!device) { + throw new NotFoundError(`Device $${this.deviceUuid} not found`); + } + const deviceAuth = await DeviceAuthRepo.findOneByDevice(device); + if (!deviceAuth) { + throw new NotFoundError(`Device Auth $${this.deviceUuid} not found`); + } + return { device, deviceAuth }; + } +} diff --git a/server/src/modules/ssh/SSHTerminalEngine.ts b/server/src/modules/ssh/SSHTerminalEngine.ts new file mode 100644 index 00000000..cf5cd80b --- /dev/null +++ b/server/src/modules/ssh/SSHTerminalEngine.ts @@ -0,0 +1,18 @@ +import { Socket } from 'socket.io'; +import { PseudoTtyOptions } from 'ssh2'; +import SSHTerminalInstance from './SSHTerminalInstance'; + +const state: SSHTerminalInstance[] | never = []; + +export function registerSshSession( + deviceUuid: string, + socket: Socket, + ttyOptions: PseudoTtyOptions, +) { + if (state[deviceUuid]) { + state[deviceUuid].stop(); + delete state[deviceUuid]; + } + state[deviceUuid] = new SSHTerminalInstance(deviceUuid, socket, ttyOptions); + state[deviceUuid].start(); +} diff --git a/server/src/modules/ssh/SSHTerminalInstance.ts b/server/src/modules/ssh/SSHTerminalInstance.ts new file mode 100644 index 00000000..a7721811 --- /dev/null +++ b/server/src/modules/ssh/SSHTerminalInstance.ts @@ -0,0 +1,152 @@ +import * as util from 'node:util'; +import pino from 'pino'; +import { Socket } from 'socket.io'; +import { PseudoTtyOptions } from 'ssh2'; +import _logger from '../../logger'; +import SSHConnectionInstance from './SSHConnectionInstance'; + +export default class SSHTerminalInstance { + private sshConnectionInstance: SSHConnectionInstance; + private socket: Socket; + private logger: pino.Logger; + private readonly ttyOptions: PseudoTtyOptions; + + constructor(deviceUuid: string, socket: Socket, ttyOptions: PseudoTtyOptions) { + this.sshConnectionInstance = new SSHConnectionInstance(deviceUuid); + this.socket = socket; + this.logger = _logger.child( + { + module: `SSHTerminalInstance`, + }, + { msgPrefix: '[SSH_TERMINAL_INSTANCE] - ' }, + ); + this.ttyOptions = ttyOptions; + } + + async start() { + this.logger.info('Starting SSHTerminalInstance'); + this.bind(); + this.logger.info('await connect'); + await this.sshConnectionInstance.connect(); + } + + async stop() { + try { + this.sshConnectionInstance.ssh.end(); + } catch (error) { + this.logger.error(error); + } + } + + private bind() { + this.logger.info('bind'); + + this.sshConnectionInstance.ssh.on('banner', (data) => { + // need to convert to cr/lf for proper formatting + this.socket.emit('ssh:data', data.replace(/\r?\n/g, '\r\n').toString()); + }); + + this.sshConnectionInstance.ssh.on('ready', () => { + this.logger.info('SSH CONNECTION ESTABLISHED'); + this.socket.emit('ssh:status', { status: 'OK', message: 'SSH CONNECTION ESTABLISHED' }); + const { term, cols, rows } = this.ttyOptions; + this.sshConnectionInstance.ssh.shell({ term, cols, rows }, (err, stream) => { + if (err) { + this.logger.error(err); + this.sshConnectionInstance.ssh.end(); + this.socket.disconnect(true); + return; + } + this.socket.once('disconnect', (reason) => { + this.logger.warn(`CLIENT SOCKET DISCONNECT: ${util.inspect(reason)}`); + this.socket.emit('ssh:status', { + status: 'DISCONNECT', + message: 'SSH CONNECTION DISCONNECTED', + }); + this.sshConnectionInstance.ssh.end(); + }); + this.socket.on('error', (errMsg) => { + this.logger.error(errMsg); + this.sshConnectionInstance.ssh.end(); + this.socket.disconnect(true); + }); + + this.socket.on('ssh:resize', (data) => { + this.ttyOptions.rows = data.rows; + this.ttyOptions.cols = data.cols; + stream.setWindow( + this.ttyOptions.rows as number, + this.ttyOptions.cols as number, + this.ttyOptions.height as number, + this.ttyOptions.width as number, + ); + this.logger.info(`SOCKET RESIZE: ${JSON.stringify([data.rows, data.cols])}`); + }); + this.socket.on('ssh:data', (data) => { + this.logger.info(`write on stream: ${data}`); + stream.write(data); + }); + stream.on('data', (data) => { + this.logger.info(`received on stream: ${data.toString('utf-8')}`); + this.socket.emit('ssh:data', data.toString('utf-8')); + }); + stream.on('close', (code, signal) => { + this.logger.warn(`STREAM CLOSE: ${util.inspect([code, signal])}`); + if (code !== 0 && typeof code !== 'undefined') { + this.logger.error('STREAM CLOSE', util.inspect({ message: [code, signal] })); + } + this.socket.disconnect(true); + this.sshConnectionInstance.ssh.end(); + }); + stream.stderr.on('data', (data) => { + this.logger.error(`STDERR: ${data}`); + }); + }); + }); + + // @ts-expect-error TODO: to investigate + this.sshConnectionInstance.ssh.on('end', (err) => { + if (err) { + this.logger.error(err); + } + this.logger.error('CONN END BY HOST'); + this.socket.disconnect(true); + }); + // @ts-expect-error TODO: to investigate + this.sshConnectionInstance.ssh.on('close', (err) => { + if (err) { + this.logger.error(err); + } + this.logger.error('CONN CLOSE'); + this.socket.disconnect(true); + }); + this.sshConnectionInstance.ssh.on('error', (err) => this.handleConnectionError(err)); + + this.sshConnectionInstance.ssh.on( + 'keyboard-interactive', + (_name, _instructions, _instructionsLang, _prompts, finish) => { + this.logger.info('CONN keyboard-interactive'); + // TODO: to handle + //finish([socket.request.session.userpassword]); + }, + ); + } + + handleConnectionError(err: any) { + let msg = util.inspect(err); + if (err?.level === 'client-authentication') { + msg = `Authentication failure from=${this.socket.handshake.address}`; + } + if (err?.code === 'ENOTFOUND') { + msg = `Host not found: ${err.hostname}`; + } + if (err?.level === 'client-timeout') { + msg = `Connection Timeout`; + } + this.socket.emit('ssh:status', { + status: 'DISCONNECT', + message: msg, + }); + this.logger.error(msg); + } +} diff --git a/server/src/services/socket/container-logs.ts b/server/src/services/socket/container-logs.ts new file mode 100644 index 00000000..df04c846 --- /dev/null +++ b/server/src/services/socket/container-logs.ts @@ -0,0 +1,77 @@ +import Joi from 'joi'; +import { DateTime } from 'luxon'; +import ContainerRepo from '../../data/database/repository/ContainerRepo'; +import PinoLogger from '../../logger'; +import pinoLogger from '../../logger'; +import { SSMSocket, SSMSocketServer } from '../../middlewares/Socket'; +import { Kind } from '../../modules/docker/core/Component'; +import WatcherEngine from '../../modules/docker/core/WatcherEngine'; +import Docker from '../../modules/docker/watchers/providers/docker/Docker'; + +const containerSchema = Joi.object({ + containerId: Joi.string().required(), + from: Joi.number().optional().default(DateTime.now().toUnixInteger()), +}); + +const logger = PinoLogger.child({ module: 'SocketService' }, { msgPrefix: '[CONTAINER_LOGS] - ' }); + +export function getContainerLogs({ io, socket }: { io: SSMSocketServer; socket: SSMSocket }) { + return async (payload, callback) => { + logger.info('getContainerLogs'); + + if (typeof callback !== 'function') { + logger.error('callback not fun'); + return; + } + const { error, value } = containerSchema.validate(payload); + if (error) { + logger.error(error); + return callback({ + status: 'Bad Request', + error: error.details?.map((e) => e.message).join(', '), + }); + } + const container = await ContainerRepo.findContainerById(value.containerId); + if (!container) { + logger.error(`Container Id ${value.containerId} not found`); + return callback({ + status: 'Bad Request', + error: `Container Id ${value.containerId} not found`, + }); + } + const registeredComponent = WatcherEngine.getStates().watcher[ + WatcherEngine.buildId(Kind.WATCHER, 'docker', container.watcher) + ] as Docker; + if (!registeredComponent) { + return callback({ + status: 'Bad Request', + error: `Watcher is not registered ${container.watcher}`, + }); + } + try { + const from = parseInt(value.from); + + logger.info(`getting container (${container.id} logs from ${from}`); + const getContainerLogsCallback = (data: string) => { + socket.emit('logs:newLogs', { data: data }); + }; + + const closingCallback = registeredComponent.getContainerLiveLogs( + container.id, + from, + getContainerLogsCallback, + ); + socket.on('logs:closing', closingCallback); + socket.on('disconnect', closingCallback); + } catch (error: any) { + logger.error(error); + return callback({ + status: 'Internal Error', + error: error.message, + }); + } + callback({ + status: 'OK', + }); + }; +} diff --git a/server/src/services/socket/ssh-session.ts b/server/src/services/socket/ssh-session.ts new file mode 100644 index 00000000..ff56cc5e --- /dev/null +++ b/server/src/services/socket/ssh-session.ts @@ -0,0 +1,56 @@ +import Joi from 'joi'; +import DeviceRepo from '../../data/database/repository/DeviceRepo'; +import PinoLogger from '../../logger'; +import { SSMSocket, SSMSocketServer } from '../../middlewares/Socket'; +import { registerSshSession } from '../../modules/ssh/SSHTerminalEngine'; + +const logger = PinoLogger.child({ module: 'SocketService' }, { msgPrefix: '[SSH_SESSION] - ' }); + +const sshSession = Joi.object({ + deviceUuid: Joi.string().required(), + rows: Joi.number().required(), + cols: Joi.number().required(), +}); + +export function startSSHSession({ io, socket }: { io: SSMSocketServer; socket: SSMSocket }) { + return async (payload, callback) => { + logger.info('startSSHSession'); + + if (typeof callback !== 'function') { + logger.error('callback not fun'); + return; + } + const { error, value } = sshSession.validate(payload); + if (error) { + logger.error(error); + return callback({ + status: 'Bad Request', + error: error.details?.map((e) => e.message).join(', '), + }); + } + const device = await DeviceRepo.findOneByUuid(value.deviceUuid); + if (!device) { + logger.error(`Device Id ${value.deviceUuid} not found`); + return callback({ + status: 'Bad Request', + error: `Device Id ${value.deviceUuid} not found`, + }); + } + + try { + registerSshSession(value.deviceUuid, socket, { + rows: value.rows, + cols: value.cols, + }); + } catch (error: any) { + logger.error(error); + return callback({ + status: 'Internal Error', + error: error.message, + }); + } + callback({ + status: 'OK', + }); + }; +} diff --git a/server/src/services/user/login.ts b/server/src/services/user/login.ts index 4f90e778..9a6530d3 100644 --- a/server/src/services/user/login.ts +++ b/server/src/services/user/login.ts @@ -30,6 +30,7 @@ export const login = asyncHandler(async (req, res, next) => { const token = jwt.sign(JSON.stringify(payload), SECRET); new SuccessResponse('Login success', { + token: token, currentAuthority: user.role, } as API.LoginInfo).send( res.cookie('jwt', token, { diff --git a/server/src/tests/unit-tests/modules/docker/DockerAPIHelper.test.ts b/server/src/tests/unit-tests/helpers/ssh/SSHCredentialsHelper.test.ts similarity index 89% rename from server/src/tests/unit-tests/modules/docker/DockerAPIHelper.test.ts rename to server/src/tests/unit-tests/helpers/ssh/SSHCredentialsHelper.test.ts index 0c2e6f1e..64739368 100644 --- a/server/src/tests/unit-tests/modules/docker/DockerAPIHelper.test.ts +++ b/server/src/tests/unit-tests/helpers/ssh/SSHCredentialsHelper.test.ts @@ -2,11 +2,11 @@ import { SsmAnsible } from 'ssm-shared-lib'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Device from '../../../../data/database/model/Device'; import DeviceAuth from '../../../../data/database/model/DeviceAuth'; +import SSHCredentialsHelper from '../../../../helpers/ssh/SSHCredentialsHelper'; import * as vault from '../../../../modules/ansible-vault/ansible-vault'; -import DockerAPIHelper from '../../../../modules/docker/core/DockerAPIHelper'; // The test cases -describe('getDockerSshConnectionOptions', () => { +describe('SSHCredentialsHelper', () => { // Mock the vaultDecrypt function vi.mock('../../../../modules/ansible-vault/ansible-vault', async (importOriginal) => { return { @@ -49,7 +49,7 @@ describe('getDockerSshConnectionOptions', () => { test('should handle key-based SSHType for default Docker SSH', async () => { deviceAuth.authType = SsmAnsible.SSHType.KeyBased; - const result = DockerAPIHelper.getDockerSshConnectionOptions(device, deviceAuth); + const result = SSHCredentialsHelper.getDockerSshConnectionOptions(device, deviceAuth); expect(result).resolves.toMatchObject({ protocol: 'ssh', @@ -76,7 +76,7 @@ describe('getDockerSshConnectionOptions', () => { deviceAuth.authType = SsmAnsible.SSHType.UserPassword; deviceAuth.sshPwd = 'sshpwd'; - const result = await DockerAPIHelper.getDockerSshConnectionOptions(device, deviceAuth); + const result = await SSHCredentialsHelper.getDockerSshConnectionOptions(device, deviceAuth); expect(result).toMatchObject({ protocol: 'ssh', @@ -107,7 +107,7 @@ describe('getDockerSshConnectionOptions', () => { deviceAuth.dockerCustomSshKey = 'sshkey'; deviceAuth.dockerCustomSshKeyPass = 'sshkeypass'; - const result = await DockerAPIHelper.getDockerSshConnectionOptions(device, deviceAuth); + const result = await SSHCredentialsHelper.getDockerSshConnectionOptions(device, deviceAuth); expect(result).toMatchObject({ protocol: 'ssh', @@ -138,7 +138,7 @@ describe('getDockerSshConnectionOptions', () => { deviceAuth.dockerCustomSshUser = '$customUser'; deviceAuth.dockerCustomSshPwd = 'sshcustompwd'; - const result = await DockerAPIHelper.getDockerSshConnectionOptions(device, deviceAuth); + const result = await SSHCredentialsHelper.getDockerSshConnectionOptions(device, deviceAuth); expect(result).toMatchObject({ protocol: 'ssh', diff --git a/server/src/use-cases/DeviceUseCases.ts b/server/src/use-cases/DeviceUseCases.ts index c50e729f..023b1530 100644 --- a/server/src/use-cases/DeviceUseCases.ts +++ b/server/src/use-cases/DeviceUseCases.ts @@ -1,6 +1,7 @@ import DockerModem from 'docker-modem'; import Dockerode from 'dockerode'; import { API, SettingsKeys, SsmAnsible, SsmStatus } from 'ssm-shared-lib'; +import SSHCredentialsHelper from '../helpers/ssh/SSHCredentialsHelper'; import { InternalError } from '../middlewares/api/ApiError'; import { setToCache } from '../data/cache'; import Device, { DeviceModel } from '../data/database/model/Device'; @@ -15,7 +16,6 @@ import PinoLogger from '../logger'; import { DEFAULT_VAULT_ID, vaultEncrypt } from '../modules/ansible-vault/ansible-vault'; import Inventory from '../modules/ansible/utils/InventoryTransformer'; import { getCustomAgent } from '../modules/docker/core/CustomAgent'; -import DockerAPIHelper from '../modules/docker/core/DockerAPIHelper'; import Shell from '../modules/shell'; import PlaybookUseCases from './PlaybookUseCases'; @@ -205,7 +205,7 @@ async function checkDockerConnection( becomePass: becomePass ? await vaultEncrypt(becomePass, DEFAULT_VAULT_ID) : undefined, sshKeyPass: sshKeyPass ? await vaultEncrypt(sshKeyPass, DEFAULT_VAULT_ID) : undefined, }; - const options = await DockerAPIHelper.getDockerSshConnectionOptions( + const options = await SSHCredentialsHelper.getDockerSshConnectionOptions( mockedDeviceAuth.device, mockedDeviceAuth, ); @@ -231,7 +231,7 @@ async function checkDockerConnection( async function checkDeviceDockerConnection(device: Device, deviceAuth: DeviceAuth) { try { - const options = await DockerAPIHelper.getDockerSshConnectionOptions(device, deviceAuth); + const options = await SSHCredentialsHelper.getDockerSshConnectionOptions(device, deviceAuth); const agent = getCustomAgent(logger, { ...options.sshOptions, });