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}
+ />
+ } type={'primary'} onClick={onStop}>
+ Stop
+
+
+ );
+};
+
+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,
});