diff --git a/demo/jccm-bastionhost-2.gif b/demo/jccm-bastionhost-2.gif new file mode 100644 index 0000000..4a3c864 Binary files /dev/null and b/demo/jccm-bastionhost-2.gif differ diff --git a/demo/jccm-bastionhost-3.gif b/demo/jccm-bastionhost-3.gif new file mode 100644 index 0000000..dffc04b Binary files /dev/null and b/demo/jccm-bastionhost-3.gif differ diff --git a/demo/jccm-bastionhost.gif b/demo/jccm-bastionhost.gif new file mode 100644 index 0000000..cb5991f Binary files /dev/null and b/demo/jccm-bastionhost.gif differ diff --git a/jccm/package-lock.json b/jccm/package-lock.json index 2e55c1d..567fc3a 100644 --- a/jccm/package-lock.json +++ b/jccm/package-lock.json @@ -1,12 +1,12 @@ { "name": "jccm", - "version": "1.1.1", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jccm", - "version": "1.1.1", + "version": "1.2.1", "license": "MIT", "dependencies": { "@babel/core": "^7.24.4", diff --git a/jccm/package.json b/jccm/package.json index 5a67bbe..bcda7d6 100644 --- a/jccm/package.json +++ b/jccm/package.json @@ -1,7 +1,7 @@ { "name": "jccm", "productName": "Juniper Cloud Connection Manager", - "version": "1.2.0", + "version": "1.2.1", "description": "Juniper Cloud Connection Manager", "main": ".webpack/main", "scripts": { diff --git a/jccm/src/Frontend/Layout/BastionHostButton.js b/jccm/src/Frontend/Layout/BastionHostButton.js index d224b57..56a4826 100644 --- a/jccm/src/Frontend/Layout/BastionHostButton.js +++ b/jccm/src/Frontend/Layout/BastionHostButton.js @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { Button, Tooltip, tokens } from '@fluentui/react-components'; +import { Button, Tooltip, Text, tokens } from '@fluentui/react-components'; import { HexagonThreeFilled, HexagonThreeRegular } from '@fluentui/react-icons'; import useStore from '../Common/StateStore'; @@ -7,17 +7,23 @@ import { RotatingIcon } from './ChangeIcon'; export const BastionHostButton = () => { const { settings, toggleBastionHostActive } = useStore(); - const bastionHost = settings?.bastionHost || {}; // Determine the status of bastionHost - const bastionHostStatus = () => { - if (Object.keys(bastionHost).length === 0) return 'not configured'; - return bastionHost.active ? 'active' : 'inactive'; + const getBastionHostStatus = () => { + const bastionHost = settings?.bastionHost || {}; + const status = + Object.keys(bastionHost).length === 0 ? 'not configured' : bastionHost.active ? 'active' : 'inactive'; + return status; + }; + + const getBastionHostName = () => { + const name = settings?.bastionHost ? `Host: ${settings.bastionHost.host} Port: ${settings.bastionHost.port}` : ''; + return name; }; // Get button details based on bastionHost status const getButtonDetails = () => { - const status = bastionHostStatus(); + const status = getBastionHostStatus(); switch (status) { case 'not configured': @@ -25,23 +31,15 @@ export const BastionHostButton = () => { icon: , color: tokens.colorNeutralStroke1Hover, }; - case 'active': + case 'inactive': return { - icon: ( - - ), - - color: tokens.colorNeutralForeground2BrandHover, + icon: , + color: tokens.colorNeutralStroke1Hover, }; - case 'inactive': + case 'active': return { icon: , - color: tokens.colorNeutralStrokeAccessibleHover, + color: tokens.colorNeutralForeground2BrandHover, }; default: return { @@ -53,15 +51,18 @@ export const BastionHostButton = () => { const { icon, color } = getButtonDetails(); - // useEffect(() => { - // if (process.env.NODE_ENV !== 'production') { - // console.log('BastionHostButton: bastionHost:', settings?.bastionHost); - // } - // }, [settings?.bastionHost]); - return ( + + Bastion Host is {getBastionHostStatus()}. + + + {getBastionHostName()} + + + } relationship='label' withArrow positioning='above-end' diff --git a/jccm/src/Frontend/Layout/Devices.js b/jccm/src/Frontend/Layout/Devices.js index 53ed8d9..fa501f9 100644 --- a/jccm/src/Frontend/Layout/Devices.js +++ b/jccm/src/Frontend/Layout/Devices.js @@ -1,8 +1,8 @@ const { electronAPI } = window; -export const getDeviceFacts = async (device, upperSerialNumber=false) => { +export const getDeviceFacts = async (device, upperSerialNumber=false, bastionHost = {}) => { const { address, port, username, password, timeout } = device; - const response = await electronAPI.saGetDeviceFacts({ address, port, username, password, timeout, upperSerialNumber }); + const response = await electronAPI.saGetDeviceFacts({ address, port, username, password, timeout, upperSerialNumber, bastionHost }); if (response.facts) { return { status: true, result: response.reply }; @@ -11,11 +11,13 @@ export const getDeviceFacts = async (device, upperSerialNumber=false) => { } }; -export const adoptDevices = async (device, jsiTerm=false, deleteOutboundSSHTerm=false) => { +export const adoptDevices = async (device, jsiTerm=false, deleteOutboundSSHTerm=false, bastionHost = {}) => { const { address, port, username, password, organization, site } = device; - const response = await electronAPI.saAdoptDevice({ address, port, username, password, organization, site, jsiTerm, deleteOutboundSSHTerm }); + const response = await electronAPI.saAdoptDevice({ address, port, username, password, organization, site, jsiTerm, deleteOutboundSSHTerm, bastionHost }); - if (response.adopt) { + // console.log('>>>>adoptDevices -> response: ', response); + + if (response?.adopt) { return { status: true, result: response.reply }; } else { console.log('adoptDevice has failed', response); diff --git a/jccm/src/Frontend/Layout/Footer.js b/jccm/src/Frontend/Layout/Footer.js index 62d2c49..8639702 100644 --- a/jccm/src/Frontend/Layout/Footer.js +++ b/jccm/src/Frontend/Layout/Footer.js @@ -9,7 +9,9 @@ import { BastionHostButton } from './BastionHostButton'; export default () => { const { isUserLoggedIn, inventory, deviceFacts, cloudDevices, cloudInventory } = useStore(); const [countOfOrgOrSiteUnmatched, setCountOfOrgOrSiteUnmatched] = useState(0); - + const { settings } = useStore(); + const [isBastionHostEmpty, setIsBastionHostEmpty] = useState(false); + const countOfDeviceFacts = Object.keys(deviceFacts).length; const countOfAdoptedDevices = Object.values(deviceFacts).filter( @@ -60,6 +62,12 @@ export default () => { return () => clearTimeout(timer); // Cleanup timer on component unmount }, [inventory, cloudDevices]); + useEffect(() => { + const bastionHost = settings?.bastionHost || {}; + const isEmpty = Object.keys(bastionHost).length === 0; + setIsBastionHostEmpty(isEmpty); + }, [settings]); + return (
{ overflow: 'visible', }} > - {/* */} + {!isBastionHostEmpty && }
{isFormValid && (
{ height: window.innerHeight, }); - const onTabSelect = (event, data) => { setSelectedTab(data.value); }; const handleClose = () => { onClose(); - } + }; return ( { height: '100%', flexDirection: 'column', overflow: 'hidden', - justifyContent: 'flex-start' + justifyContent: 'flex-start', }} > - + + General + + - } - > - General - - {/* } - > - Bastion Host - */} - + Bastion Host + +
@@ -135,7 +137,7 @@ export const GlobalSettings = ({ title, isOpen, onClose }) => { display: selectedTab === 'BastionHost' ? 'flex' : 'none', width: '100%', height: '100%', - marginTop: '20px' + marginTop: '20px', }} > diff --git a/jccm/src/Frontend/Layout/InventorySearch/CustomProgressBar.js b/jccm/src/Frontend/Layout/InventorySearch/CustomProgressBar.js index 664e2cc..22d9681 100644 --- a/jccm/src/Frontend/Layout/InventorySearch/CustomProgressBar.js +++ b/jccm/src/Frontend/Layout/InventorySearch/CustomProgressBar.js @@ -1,5 +1,15 @@ import React from 'react'; -import { Field, ProgressBar, Text, tokens } from '@fluentui/react-components'; +import { + Field, + ProgressBar, + Text, + InfoLabel, + Link, + Popover, + PopoverSurface, + PopoverTrigger, + tokens, +} from '@fluentui/react-components'; import { RocketFilled, FireFilled, FlagCheckeredFilled } from '@fluentui/react-icons'; // Adjust the import path as necessary const getRandomFontSize = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; @@ -40,6 +50,7 @@ export const CustomProgressBar = ({ message, size, max, value, isStart }) => { display: 'flex', flexDirection: 'row', justifyContent: 'space-between', + alignItems: 'center', width: '100%', gap: '20px', marginBottom: '3px', @@ -79,25 +90,101 @@ export const CustomProgressBar = ({ message, size, max, value, isStart }) => { {' devices'}
-
+
{Object.entries(message.hostStatusCount).map(([status, count]) => (
- - {`${status}: `} + {status.toLowerCase().includes('ssh client error') ? ( + + + + + {`${status.toLowerCase()}: `} + + + {count} + + + + + +
+ {Object.entries(message.sshClientErrorCount).map( + ([message, messageCount]) => ( + + {`${message}: `} + + {messageCount} + + + ) + )} +
+
+
+ ) : ( - {count} + {`${status}: `} + + {count} + -
+ )}
))}
@@ -108,6 +195,7 @@ export const CustomProgressBar = ({ message, size, max, value, isStart }) => { display: 'flex', flexDirection: 'row', justifyContent: 'space-between', + alignItems: 'center', gap: '5px', }} > diff --git a/jccm/src/Frontend/Layout/InventorySearch/InventorySearch.js b/jccm/src/Frontend/Layout/InventorySearch/InventorySearch.js index e19bc48..76d9fd7 100644 --- a/jccm/src/Frontend/Layout/InventorySearch/InventorySearch.js +++ b/jccm/src/Frontend/Layout/InventorySearch/InventorySearch.js @@ -73,10 +73,10 @@ const InventorySearchCard = ({ isOpen, onClose }) => { const [facts, setFacts] = useState([]); const factsColumns = [ - { label: 'Address', name: 'address', width: 8 }, + { label: 'Address', name: 'address', width: 12 }, { label: 'Port', name: 'port', width: 5 }, - { label: 'Username', name: 'username', width: 10 }, - { label: 'Password', name: 'password', width: 10 }, + { label: 'Username', name: 'username', width: 8 }, + { label: 'Password', name: 'password', width: 8 }, { label: 'Host Name', name: 'hostName', width: 10 }, { label: 'Hardware Model', name: 'hardwareModel', width: 10 }, { label: 'Serial Number', name: 'serialNumber', width: 10 }, diff --git a/jccm/src/Frontend/Layout/InventorySearch/InventorySearchControl.js b/jccm/src/Frontend/Layout/InventorySearch/InventorySearchControl.js index 0ed2dad..a7841c7 100644 --- a/jccm/src/Frontend/Layout/InventorySearch/InventorySearchControl.js +++ b/jccm/src/Frontend/Layout/InventorySearch/InventorySearchControl.js @@ -29,6 +29,8 @@ import { TriangleLeftRegular, bundleIcon, } from '@fluentui/react-icons'; + +import useStore from '../../Common/StateStore'; import { RotatingIcon } from '../ChangeIcon'; import { CustomProgressBar } from './CustomProgressBar'; import { getHostListMultiple, getHostCountMultiple } from './InventorySearchUtils'; @@ -41,12 +43,13 @@ import { getDeviceFacts } from '../Devices'; export const InventorySearchControl = ({ subnets, startCallback, endCallback, onAddFact }) => { const { notify } = useNotify(); // Correctly use the hook here + const { settings } = useStore(); const [isStart, setIsStart] = useState(false); const [searchRate, setSearchRate] = useState(10); const [hostSeq, setHostSeq] = useState(0); const [hostStatusCount, setHostStatusCount] = useState({}); - + const [sshClientErrorCount, setSshClientErrorCount] = useState({}); const isStartRef = useRef(null); const hostSeqRef = useRef(null); const hostStatusCountRef = useRef(null); @@ -62,7 +65,7 @@ export const InventorySearchControl = ({ subnets, startCallback, endCallback, on hostStatusCountRef.current = hostStatusCount; }, [isStart, hostSeq, hostStatusCount]); - const updateHostStatusCount = (status) => { + const updateHostStatusCount2 = (status) => { setHostStatusCount((prevStatus) => { const updatedStatus = { ...prevStatus }; if (updatedStatus[status]) { @@ -74,18 +77,48 @@ export const InventorySearchControl = ({ subnets, startCallback, endCallback, on }); }; + const updateCount = (result) => { + const { status, message } = result; + + setHostStatusCount((prevStatus) => { + const updatedStatus = { ...prevStatus }; + if (updatedStatus[status]) { + updatedStatus[status] += 1; + } else { + updatedStatus[status] = 1; + } + return updatedStatus; + }); + + if (status === 'SSH Client Error') { + setSshClientErrorCount((prevMessage) => { + const updatedMessage = { ...prevMessage }; + if (updatedMessage[message]) { + updatedMessage[message] += 1; + } else { + updatedMessage[message] = 1; + } + return updatedMessage; + }); + } + }; + const fetchDeviceFacts = async (device) => { const maxRetries = 2; const retryInterval = 1000; // 1 seconds in milliseconds let response; + const bastionHost = settings?.bastionHost || {}; + for (let attempt = 1; attempt <= maxRetries; attempt++) { - response = await getDeviceFacts({ ...device, timeout: 3000 }); + response = await getDeviceFacts({ ...device, timeout: 5000 }, false, bastionHost); + // console.log(`${device.address}: response: `, response); if (response.status) { - updateHostStatusCount(response.result.status); - + // updateHostStatusCount(response.result.status); + updateCount(response.result); + const { address, port, username, password } = device; if (!!response.result.vc) { @@ -136,7 +169,8 @@ export const InventorySearchControl = ({ subnets, startCallback, endCallback, on } } - updateHostStatusCount(response.result.status); + // updateHostStatusCount(response.result.status); + updateCount(response.result); return response; }; @@ -151,6 +185,9 @@ export const InventorySearchControl = ({ subnets, startCallback, endCallback, on const startTime = Date.now(); const interval = 1000 / searchRate; // Desired interval between each command + setHostStatusCount({}); + setSshClientErrorCount({}); + for (const device of getHostListMultiple(subnets)) { promises.push(fetchDeviceFacts(device)); // Add the promise to the array setHostSeq(n++); @@ -288,7 +325,7 @@ export const InventorySearchControl = ({ subnets, startCallback, endCallback, on }} > { const { notify } = useNotify(); // Correctly use the hook here const fileInputRef = useRef(null); + const { settings } = useStore(); + const [isBastionHostEmpty, setIsBastionHostEmpty] = useState(false); // Calculate the total sum of hostCounts const totalHostCount = getHostCountMultiple(items); @@ -156,6 +159,12 @@ export const SubnetResult = ({ columns, items, onDeleteSubnet, onImportSubnet = writeFile(wb, fileName); }; + useEffect(() => { + const bastionHost = settings?.bastionHost || {}; + const isEmpty = Object.keys(bastionHost).length === 0; + setIsBastionHostEmpty(isEmpty); + }, [settings]); + return (
-
- {/*
- -
*/} +
+ {!isBastionHostEmpty && ( +
+ +
+ )} Total Subnets: {items?.length} Total Hosts: {totalHostCount.toLocaleString()} diff --git a/jccm/src/Frontend/Layout/InventoryTreeMenuCloud.js b/jccm/src/Frontend/Layout/InventoryTreeMenuCloud.js index 289f471..b9aaacd 100644 --- a/jccm/src/Frontend/Layout/InventoryTreeMenuCloud.js +++ b/jccm/src/Frontend/Layout/InventoryTreeMenuCloud.js @@ -351,7 +351,7 @@ const RenderCloudInventoryTree = ({ nodes, openItems, onOpenChange }) => { const onReleaseConfirmButton = async (orgId, mac) => { setIsOpenReleaseDialog(false); - console.log('>>>> mac address:', mac); + // console.log('>>>> mac address:', mac); const data = await electronAPI.saProxyCall({ api: `orgs/${orgId}/inventory`, diff --git a/jccm/src/Frontend/Layout/InventoryTreeMenuLocal.js b/jccm/src/Frontend/Layout/InventoryTreeMenuLocal.js index 93ac566..089d9cf 100644 --- a/jccm/src/Frontend/Layout/InventoryTreeMenuLocal.js +++ b/jccm/src/Frontend/Layout/InventoryTreeMenuLocal.js @@ -800,7 +800,7 @@ const InventoryTreeMenuLocal = () => { font='numeric' style={{ color: 'red' }} > - {isChecking.error} - Retry attempt {isChecking.retry} + {isChecking.error} - Retry {isChecking.retry} )}
@@ -830,7 +830,7 @@ const InventoryTreeMenuLocal = () => { font='numeric' style={{ color: 'red' }} > - {isAdopting[path]?.error} - Retry attempt {isAdopting[path]?.retry} + {isAdopting[path]?.error} - Retry {isAdopting[path]?.retry} )}
@@ -861,15 +861,17 @@ const InventoryTreeMenuLocal = () => { }; const fetchDeviceFacts = async (device) => { - const maxRetries = 3; + const maxRetries = 2; const retryInterval = 10000; // 10 seconds in milliseconds let response; setIsChecking(device._path, { status: true, retry: 0 }); resetIsAdopting(device._path); + const bastionHost = settings?.bastionHost || {}; + for (let attempt = 1; attempt <= maxRetries; attempt++) { - response = await getDeviceFacts({ ...device, timeout: 5000 }, true); + response = await getDeviceFacts({ ...device, timeout: 5000 }, true, bastionHost); if (response.status) { setDeviceFacts(device._path, response.result); resetIsChecking(device._path); @@ -879,11 +881,16 @@ const InventoryTreeMenuLocal = () => { `${device.address}:${device.port} - Error retrieving facts on attempt ${attempt}:`, response ); - if (response.result?.status.toLowerCase().includes('authentication failed')) { + if (response.result?.status?.toLowerCase().includes('authentication failed')) { + deleteDeviceFacts(device._path); + setIsChecking(device._path, { status: false, retry: -1, error: response.result?.message }); + return; + } else if (response.result?.status?.toLowerCase().includes('ssh client error')) { deleteDeviceFacts(device._path); setIsChecking(device._path, { status: false, retry: -1, error: response.result?.message }); return; } + setIsChecking(device._path, { status: true, retry: attempt, error: response.result?.message }); await new Promise((resolve) => setTimeout(resolve, retryInterval)); } @@ -946,15 +953,19 @@ const InventoryTreeMenuLocal = () => { }; const actionAdoptDevice = async (device, jsiTerm = false, deleteOutboundSSHTerm = false) => { - const maxRetries = 6; - const retryInterval = 15 * 1000; // 15 seconds in milliseconds + const maxRetries = 2; + const retryInterval = 15000; + let response; setIsAdopting(device._path, { status: true, retry: 0 }); resetIsChecking(device._path); + const bastionHost = settings?.bastionHost || {}; + for (let attempt = 1; attempt <= maxRetries; attempt++) { - const response = await adoptDevices(device, jsiTerm, deleteOutboundSSHTerm); - if (response.status) { + response = await adoptDevices(device, jsiTerm, deleteOutboundSSHTerm, bastionHost); + + if (response?.status) { resetIsAdopting(device.path, false); return; } else { @@ -963,24 +974,26 @@ const InventoryTreeMenuLocal = () => { response ); - if (response.result?.status.toLowerCase().includes('authentication failed')) { - setIsAdopting(device._path, { status: false, retry: -1, error: response.result?.message }); + if (response?.result?.status?.toLowerCase().includes('authentication failed')) { + setIsAdopting(device._path, { status: false, retry: -1, error: response?.result?.message }); return; } - setIsAdopting(device._path, { status: true, retry: attempt, error: response.result?.message }); + setIsAdopting(device._path, { status: true, retry: attempt, error: response?.result?.message }); await new Promise((resolve) => setTimeout(resolve, retryInterval)); // Wait before retrying } } - resetIsAdopting(device._path, { status: false, retry: -1, error: response.result?.message }); + resetIsAdopting(device._path, { status: false, retry: -1, error: response?.result?.message }); notify( Device Adoption Failure - - The device could not be adopted into the organization: "{device.organization}". - Error Message: {response.result.message} + +
+ The device could not be adopted into the organization: "{device.organization}". + Error Message: {response?.result.message} +
, { intent: 'error' } diff --git a/jccm/src/Services/ApiServer.js b/jccm/src/Services/ApiServer.js index 04b0026..d1c3f64 100644 --- a/jccm/src/Services/ApiServer.js +++ b/jccm/src/Services/ApiServer.js @@ -131,7 +131,9 @@ const serverGetCloudInventory = async (targetOrgs = null) => { // console.log(`Get device stats: ${JSON.stringify(cloudDeviceStates, null, 2)}`); for (const cloudDevice of cloudDeviceStates) { - SN2VSN[cloudDevice.serial] = cloudDevice.module_stat.find(item => item && item.serial); + SN2VSN[cloudDevice.serial] = cloudDevice.module_stat.find( + (item) => item && item.serial + ); } } } @@ -167,6 +169,266 @@ const serverGetCloudInventory = async (targetOrgs = null) => { return { inventory, isFilterApplied }; }; +const startSSHConnectionStandalone = (event, device, { id, cols, rows }) => { + const { address, port, username, password } = device; + + const conn = new Client(); + sshSessions[id] = conn; + + conn.on('ready', () => { + console.log(`SSH session successfully opened for id: ${id}`); + event.reply('sshSessionOpened', { id }); // Notify renderer that the session is open + + conn.shell({ cols, rows }, (err, stream) => { + if (err) { + event.reply('sshErrorOccurred', { id, message: err.message }); + return; + } + + stream.on('data', (data) => { + event.reply('sshDataReceived', { id, data: data.toString() }); + }); + + stream.on('close', () => { + event.reply('sshDataReceived', { id, data: 'The SSH session has been closed.\r\n' }); + + conn.end(); + delete sshSessions[id]; + event.reply('sshSessionClosed', { id }); + // Clean up listeners + ipcMain.removeAllListeners(`sendSSHInput-${id}`); + ipcMain.removeAllListeners(`resizeSSHSession-${id}`); + }); + + ipcMain.on(`sendSSHInput-${id}`, (_, data) => { + stream.write(data); + }); + + ipcMain.on(`resizeSSHSession-${id}`, (_, { cols, rows }) => { + stream.setWindow(rows, cols, 0, 0); + }); + }); + }).connect({ + host: address, + port, + username, + password, + poll: 10, // Adjust the polling interval to 10 milliseconds + keepaliveInterval: 10000, // Send keepalive every 10 seconds + keepaliveCountMax: 3, // Close the connection after 3 failed keepalives + }); + + conn.on('error', (err) => { + event.reply('sshErrorOccurred', { id, message: err.message }); + }); + + conn.on('end', () => { + delete sshSessions[id]; + }); +}; + +const startSSHConnectionProxy = (event, device, bastionHost, { id, cols, rows }) => { + const conn = new Client(); + sshSessions[id] = conn; + + conn.on('ready', () => { + console.log(`SSH session successfully opened for id: ${id}`); + event.reply('sshSessionOpened', { id }); // Notify renderer that the session is open + + conn.shell({ cols, rows }, (err, stream) => { + if (err) { + event.reply('sshErrorOccurred', { id, message: err.message }); + return; + } + + let initialOutputBuffer = ''; + let sshClientCommand = ''; + + const sshOptions = [ + '-o StrictHostKeyChecking=no', + '-o ConnectTimeout=3', + '-o NumberOfPasswordPrompts=1', + '-o PreferredAuthentications=keyboard-interactive', + ].join(' '); + + const linuxSSHCommand = `ssh -tt ${sshOptions} -p ${device.port} ${device.username}@${device.address};exit`; + const junosSSHCommand = `set cli prompt "> "\nstart shell command "ssh -tt ${sshOptions} -p ${device.port} ${device.username}@${device.address}"\nexit`; + + const promptPattern = /\n[\s\S]*?[@#>%$]\s$/; + + let isBastionHostOsTypeChecked = false; + let isBastionHostOsTypeCheckTimeoutPass = false; + let bastionHostOsTypeCheckTimeoutHandle; + + let isSshClientPasswordInputted = false; + let isSshClientPasswordInputTimeoutPass = false; + let sshClientPasswordInputTimeoutHandle; + + let isSshClientAuthErrorMonitoringTimeoutPass = false; + let sshClientAuthErrorMonitoringTimeoutHandle; + + const resetBastionHostOsTypeCheckTimeout = (timeout) => { + clearTimeout(bastionHostOsTypeCheckTimeoutHandle); + bastionHostOsTypeCheckTimeoutHandle = setTimeout(() => { + isBastionHostOsTypeCheckTimeoutPass = true; + }, timeout); + }; + + const resetSshClientPasswordInputTimeout = (timeout) => { + clearTimeout(sshClientPasswordInputTimeoutHandle); + sshClientPasswordInputTimeoutHandle = setTimeout(() => { + isSshClientPasswordInputTimeoutPass = true; + }, timeout); + }; + + const resetSshClientAuthErrorMonitoringTimeout = (timeout) => { + clearTimeout(sshClientAuthErrorMonitoringTimeoutHandle); + sshClientAuthErrorMonitoringTimeoutHandle = setTimeout(() => { + isSshClientAuthErrorMonitoringTimeoutPass = true; + }, timeout); + }; + + resetBastionHostOsTypeCheckTimeout(5000); + + stream.on('data', (data) => { + const output = data.toString(); + + // process.stdout.write(output); + + if (!isBastionHostOsTypeCheckTimeoutPass || !isSshClientPasswordInputTimeoutPass) { + initialOutputBuffer += output; + } + + // Check if either the timeout has passed without a check, or the check hasn't been done and it's ready + if ( + (!isBastionHostOsTypeChecked && promptPattern.test(initialOutputBuffer)) || + (isBastionHostOsTypeCheckTimeoutPass && !isBastionHostOsTypeChecked) + ) { + isBastionHostOsTypeChecked = true; + isBastionHostOsTypeCheckTimeoutPass = true; + clearTimeout(bastionHostOsTypeCheckTimeoutHandle); + + // Determine the SSH command based on the output buffer content + if (initialOutputBuffer.toLowerCase().includes('junos') && bastionHost.username !== 'root') { + sshClientCommand = junosSSHCommand; + } else { + sshClientCommand = linuxSSHCommand; + } + + // Send the determined SSH command and reset the initial output buffer + stream.write(sshClientCommand + '\n'); + initialOutputBuffer = ''; + + // Reset the password input timeout to prepare for the next step + resetSshClientPasswordInputTimeout(5000); + } + + // Check if the bastion host's OS type has been successfully checked. + if (isBastionHostOsTypeChecked) { + // Handling input for the SSH client password + if (!isSshClientPasswordInputted) { + if ( + !isSshClientPasswordInputTimeoutPass && + initialOutputBuffer.toLowerCase().includes('password:') + ) { + // Password prompt found, input the password + isSshClientPasswordInputted = true; + stream.write(device.password + '\n'); + initialOutputBuffer = ''; + resetSshClientAuthErrorMonitoringTimeout(5000); + } else if (isSshClientPasswordInputTimeoutPass) { + // Handling timeout passing without password input + isSshClientPasswordInputted = true; + initialOutputBuffer = ''; + isSshClientAuthErrorMonitoringTimeoutPass = true; + } + } else { + // Handling SSH client authentication error monitoring + if (!isSshClientAuthErrorMonitoringTimeoutPass) { + // Check if there's no error message indicating a connection issue + const connectionErrorMessage = `${device.username}@${device.address}: `; + const sshConnectionErrorMessage = `ssh: connect to host ${device.address} port ${device.port}: `; + if ( + !initialOutputBuffer.includes(connectionErrorMessage) && + !initialOutputBuffer.includes(sshConnectionErrorMessage) + ) { + event.reply('sshDataReceived', { id, data: output }); + } + } else { + // Timeout has passed, send data regardless + event.reply('sshDataReceived', { id, data: output }); + } + } + } + }); + + stream.on('close', () => { + // console.log(`|||>>>${initialOutputBuffer}<<<|||`); + + // Function to handle the extraction and cleanup of error messages + function handleSSHErrorMessage(pattern, messagePrefix) { + const regex = new RegExp(`^${pattern}(.+)$`, 'm'); + const match = initialOutputBuffer.match(regex); + + if (match && match[1]) { + const sshErrorMessage = match[1]; + const cleanedErrorMessage = sshErrorMessage.replace(/\.$/, ''); + event.reply('sshDataReceived', { + id, + data: `${messagePrefix}${cleanedErrorMessage}\r\n\r\n`, + }); + } + } + + // Check for error messages related to direct SSH or port issues + if (initialOutputBuffer.includes(`${device.username}@${device.address}: `)) { + handleSSHErrorMessage(`${device.username}@${device.address}: `, 'SSH connection: '); + } else if ( + initialOutputBuffer.includes(`ssh: connect to host ${device.address} port ${device.port}: `) + ) { + handleSSHErrorMessage( + `ssh: connect to host ${device.address} port ${device.port}: `, + '\r\nSSH connection: ' + ); + } + + conn.end(); + delete sshSessions[id]; + event.reply('sshSessionClosed', { id }); + + // Clean up listeners + ipcMain.removeAllListeners(`sendSSHInput-${id}`); + ipcMain.removeAllListeners(`resizeSSHSession-${id}`); + }); + + ipcMain.on(`sendSSHInput-${id}`, (_, data) => { + stream.write(data); + }); + + ipcMain.on(`resizeSSHSession-${id}`, (_, { cols, rows }) => { + stream.setWindow(rows, cols, 0, 0); + }); + }); + }).connect({ + host: bastionHost.host, + port: bastionHost.port, + username: bastionHost.username, + password: bastionHost.password, + readyTimeout: bastionHost.readyTimeout, + poll: 10, // Adjust the polling interval to 10 milliseconds + keepaliveInterval: 10000, // Send keepalive every 10 seconds + keepaliveCountMax: 3, // Close the connection after 3 failed keepalives + }); + + conn.on('error', (err) => { + event.reply('sshErrorOccurred', { id, message: err.message }); + }); + + conn.on('end', () => { + delete sshSessions[id]; + }); +}; + export const setupApiHandlers = () => { ipcMain.handle('saFetchAvailableClouds', async (event) => { console.log('main: saFetchAvailableClouds'); @@ -360,6 +622,8 @@ export const setupApiHandlers = () => { ipcMain.on('startSSHConnection', async (event, { id, cols, rows }) => { console.log('main: startSSHConnection: id: ' + id); const inventory = await msGetLocalInventory(); + const settings = await msLoadSettings(); + const bastionHost = settings?.bastionHost || {}; const found = inventory.filter( ({ organization, site, address, port }) => id === `/Inventory/${organization}/${site}/${address}/${port}` @@ -369,61 +633,11 @@ export const setupApiHandlers = () => { return; } const device = found[0]; - const { address, port, username, password } = device; - - const conn = new Client(); - sshSessions[id] = conn; - - conn.on('ready', () => { - console.log(`SSH session successfully opened for id: ${id}`); - event.reply('sshSessionOpened', { id }); // Notify renderer that the session is open - - conn.shell({ cols, rows }, (err, stream) => { - if (err) { - event.reply('sshErrorOccurred', { id, message: err.message }); - return; - } - - stream.on('data', (data) => { - event.reply('sshDataReceived', { id, data: data.toString() }); - }); - - stream.on('close', () => { - event.reply('sshDataReceived', { id, data: 'The SSH session has been closed.\r\n' }); - - conn.end(); - delete sshSessions[id]; - event.reply('sshSessionClosed', { id }); - // Clean up listeners - ipcMain.removeAllListeners(`sendSSHInput-${id}`); - ipcMain.removeAllListeners(`resizeSSHSession-${id}`); - }); - - ipcMain.on(`sendSSHInput-${id}`, (_, data) => { - stream.write(data); - }); - - ipcMain.on(`resizeSSHSession-${id}`, (_, { cols, rows }) => { - stream.setWindow(rows, cols, 0, 0); - }); - }); - }).connect({ - host: address, - port, - username, - password, - poll: 10, // Adjust the polling interval to 10 milliseconds - keepaliveInterval: 10000, // Send keepalive every 10 seconds - keepaliveCountMax: 3, // Close the connection after 3 failed keepalives - }); - - conn.on('error', (err) => { - event.reply('sshErrorOccurred', { id, message: err.message }); - }); - - conn.on('end', () => { - delete sshSessions[id]; - }); + if (bastionHost?.active) { + startSSHConnectionProxy(event, device, bastionHost, { id, cols, rows }); + } else { + startSSHConnectionStandalone(event, device, { id, cols, rows }); + } }); ipcMain.on('disconnectSSHSession', (event, { id }) => { @@ -439,8 +653,16 @@ export const setupApiHandlers = () => { console.log('main: saGetDeviceFacts'); try { - const { address, port, username, password, timeout, upperSerialNumber } = args; - const reply = await getDeviceFacts(address, port, username, password, timeout, upperSerialNumber); + const { address, port, username, password, timeout, upperSerialNumber, bastionHost } = args; + const reply = await getDeviceFacts( + address, + port, + username, + password, + timeout, + upperSerialNumber, + bastionHost + ); return { facts: true, reply }; } catch (error) { @@ -451,8 +673,18 @@ export const setupApiHandlers = () => { ipcMain.handle('saAdoptDevice', async (event, args) => { console.log('main: saAdoptDevice'); - const { organization, site, address, port, username, password, jsiTerm, deleteOutboundSSHTerm, ...others } = - args; + const { + organization, + site, + address, + port, + username, + password, + jsiTerm, + deleteOutboundSSHTerm, + bastionHost, + ...others + } = args; const cloudOrgs = await msGetCloudOrgs(); const orgId = cloudOrgs[organization]?.id; @@ -471,7 +703,7 @@ export const setupApiHandlers = () => { ? `delete system services outbound-ssh\n${response.cmd}\n` : `${response.cmd}\n`; - const reply = await commitJunosSetConfig(address, port, username, password, configCommand); + const reply = await commitJunosSetConfig(address, port, username, password, configCommand, bastionHost); if (reply.status === 'success' && reply.data.includes('')) { return { adopt: true, reply }; diff --git a/jccm/src/Services/Device.js b/jccm/src/Services/Device.js index 68b779e..9365377 100644 --- a/jccm/src/Services/Device.js +++ b/jccm/src/Services/Device.js @@ -30,6 +30,10 @@ const StatusErrorMessages = { status: 'Inactivity timeout', message: 'Session closed due to inactivity', }, + SSH_CLIENT_ERROR: { + status: 'SSH Client Error', + message: '', + }, }; /** @@ -40,7 +44,44 @@ const StatusErrorMessages = { * @param {number} commitInactivityTimeout - Timeout in milliseconds for inactivity on commit commands. * @returns {Promise} - A promise that resolves to an object with results. */ -function processCommands(commands, sshConfig, commandInactivityTimeout = 3000, commitInactivityTimeout = 60000) { + +const processCommands = async ( + commands, + sshConfig, + bastionHost = {}, + commandInactivityTimeout = 3000, + commitInactivityTimeout = 60000 +) => { + try { + if (bastionHost.active) { + // console.log('run processCommands proxy'); + return await processCommandsProxy( + commands, + sshConfig, + bastionHost, + commandInactivityTimeout * 2, + commitInactivityTimeout + ); + } else { + // console.log('run processCommands standalone'); + return await processCommandsStandalone( + commands, + sshConfig, + commandInactivityTimeout, + commitInactivityTimeout + ); + } + } catch (error) { + throw error; + } +}; + +function processCommandsStandalone( + commands, + sshConfig, + commandInactivityTimeout = 5000, + commitInactivityTimeout = 60000 +) { return new Promise((resolve, reject) => { const conn = new Client(); @@ -77,6 +118,7 @@ function processCommands(commands, sshConfig, commandInactivityTimeout = 3000, c const onDataReceived = (data) => { const output = data.toString(); + // process.stdout.write(output); eachCommandOutput += output; @@ -181,6 +223,251 @@ function processCommands(commands, sshConfig, commandInactivityTimeout = 3000, c }); } +function processCommandsProxy( + _commands, + sshConfig, + bastionHost, + commandInactivityTimeout = 5000, + commitInactivityTimeout = 60000, + sshConnectTimeout = 5000 +) { + const commands = [..._commands]; + + // console.log('processCommandsProxy bastionHost: ', bastionHost); + // console.log('processCommandsProxy commands: ', commands); + + return new Promise((resolve, reject) => { + const sshOptions = [ + '-o StrictHostKeyChecking=no', + '-o ConnectTimeout=3', + '-o NumberOfPasswordPrompts=1', + '-o PreferredAuthentications=keyboard-interactive', + ].join(' '); + + const linuxSSHCommand = `ssh -tt ${sshOptions} -p ${sshConfig.port} ${sshConfig.username}@${sshConfig.host}`; + const junosSSHCommand = `start shell command "ssh -tt ${sshOptions} -p ${sshConfig.port} ${sshConfig.username}@${sshConfig.host}"`; + + const conn = new Client(); + + let currentCommand = null; + let eachCommandOutput = ''; + let allCommandsOutput = ''; + let timeoutHandle; + let stream; + let sshClientPasswordPass = false; + let sshClientCommand = ''; + let sshClientError = false; + let isJunosDeviceBastionHostFound = false; + + const results = []; + const promptPattern = /\n[\s\S]*?[@#>%$]\s$/; + const resetTimeout = (timeout) => { + clearTimeout(timeoutHandle); + timeoutHandle = setTimeout(() => { + stream.end(); + conn.end(); + reject({ + ...StatusErrorMessages.INACTIVITY_TIMEOUT, + message: `${StatusErrorMessages.INACTIVITY_TIMEOUT.message}`, + }); + }, timeout); + }; + + const getCommand = () => { + if (commands.length > 0) { + const cmd = commands.shift(); + return cmd; + } else { + stream.end(); + stream.close(); + } + }; + + const onDataReceived = (data) => { + const output = data.toString(); + + // process.stdout.write(output); + + eachCommandOutput += output; + allCommandsOutput += output; + + // Check for password prompt and send the password + if ( + !sshClientPasswordPass && + currentCommand && + currentCommand.startsWith(sshClientCommand) && + eachCommandOutput.toLowerCase().includes('password:') + ) { + stream.write(`${sshConfig.password}\n`); + sshClientPasswordPass = true; + } + + if (currentCommand && currentCommand.startsWith(sshClientCommand)) { + resetTimeout(sshConnectTimeout); + } else if (currentCommand && currentCommand.startsWith('commit')) { + resetTimeout(commitInactivityTimeout); + } else { + resetTimeout(commandInactivityTimeout); + } + + // Check for a prompt in the command output + if (promptPattern.test(eachCommandOutput)) { + // Setup SSH command if it hasn't been set + if (!sshClientCommand) { + if ( + !isJunosDeviceBastionHostFound && + eachCommandOutput.toLowerCase().includes('junos') && + bastionHost.username !== 'root' + ) { + sshClientCommand = junosSSHCommand; + isJunosDeviceBastionHostFound = true; + } else { + sshClientCommand = linuxSSHCommand; + } + commands.unshift(sshClientCommand); + } + + // Handle specific Junos errors + if (isJunosDeviceBastionHostFound) { + const junosErrorPatterns = ['could not create child process', 'no more processes']; + const isErrorPresent = junosErrorPatterns.some((pattern) => + eachCommandOutput.toLowerCase().includes(pattern) + ); + + if (isErrorPresent) { + terminateConnectionWithErrorMessage('Failed to execute the SSH client'); + return; // Early exit to prevent further processing + } + } + + // Process SSH error messages using a consolidated error handler + processSSHErrorMessages(eachCommandOutput, sshConfig); + + // Reset command output buffer + eachCommandOutput = ''; + + // Process incoming commands + processIncomingCommands(); + } + + // Function to handle the extraction and cleanup of error messages + function processSSHErrorMessages(output, config) { + const patterns = [ + `^${config.username}@${config.host}: (.+)$`, + `^ssh: connect to host ${config.host} port ${config.port}: (.+)$`, + ]; + + patterns.forEach((pattern) => { + const regex = new RegExp(`${pattern}`, 'm'); + const match = output.match(regex); + + if (match && match[1]) { + const cleanedErrorMessage = match[1].replace(/\.$/, ''); + terminateConnectionWithErrorMessage(cleanedErrorMessage); + } + }); + } + + function terminateConnectionWithErrorMessage(message) { + conn.end(); + clearTimeout(timeoutHandle); + sshClientError = true; + reject({ + ...StatusErrorMessages.SSH_CLIENT_ERROR, + message: message, + }); + } + + function processIncomingCommands() { + while (true) { + const cmd = getCommand(); + if (!cmd) break; + + const parts = cmd.split(/\s+/); + const timeoutKeyword = parts[0].toLowerCase(); + const number = parseInt(parts[1], 10); + + switch (timeoutKeyword) { + case 'jcli-inactivity-timeout': + commandInactivityTimeout = number * 1000; + break; + case 'jedit-inactivity-timeout': + commitInactivityTimeout = number * 1000; + break; + default: + currentCommand = + cmd.startsWith('show') || cmd.startsWith('commit') + ? `${cmd} | display xml | no-more\n` + : `${cmd}\n`; + stream.write(currentCommand); + return; + } + } + } + }; + + conn.on('ready', () => { + const shellOptions = { + cols: 2000, // Number of columns for the terminal + rows: 80, // Number of rows for the terminal + }; + + conn.shell(shellOptions, (err, _stream) => { + if (err) { + conn.end(); + reject({ + ...StatusErrorMessages.UNREACHABLE, + }); + return; + } + + stream = _stream; + + resetTimeout(commandInactivityTimeout); + + stream.on('data', (data) => onDataReceived(data)); + + stream.on('close', () => { + if (!sshClientError) { + // console.log('>>> all commands output:', allCommandsOutput); + + const regex = //gi; + let match; + while ((match = regex.exec(allCommandsOutput)) !== null) { + results.push(match[0]); + } + resolve({ + ...StatusErrorMessages.SUCCESS, + data: results, + }); + } + conn.end(); + clearTimeout(timeoutHandle); + }); + }); + }); + conn.on('error', (err) => { + conn.end(); + if (err.level === 'client-authentication') { + reject({ + ...StatusErrorMessages.AUTHENTICATION_FAILED, + message: `Bastion Host: ${StatusErrorMessages.AUTHENTICATION_FAILED.message}`, + }); + } else if (err.level === 'client-timeout') { + reject({ + ...StatusErrorMessages.TIMEOUT, + message: `Bastion Host: ${StatusErrorMessages.TIMEOUT.message}`, + }); + } else { + reject({ + ...StatusErrorMessages.UNREACHABLE, + message: `Bastion Host: ${StatusErrorMessages.UNREACHABLE.message}`, + }); + } + }).connect(bastionHost); + }); +} + const getRpcReply = (rpcName, result) => { // Escape any special characters in rpcName to safely include it in the regex const escapedRpcName = rpcName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -195,12 +482,16 @@ const getRpcReply = (rpcName, result) => { return null; }; -export const getDeviceFacts = async (address, port, username, password, timeout, upperSerialNumber = false) => { - const commands = [ - 'show system information', - 'show virtual-chassis', - 'exit', - ]; +export const getDeviceFacts = async ( + address, + port, + username, + password, + timeout, + upperSerialNumber = false, + bastionHost = {} +) => { + const commands = ['show system information', 'show chassis hardware', 'show virtual-chassis', 'exit']; if (username === 'root') commands.unshift('cli'); @@ -213,18 +504,16 @@ export const getDeviceFacts = async (address, port, username, password, timeout, }; try { - const results = await processCommands(commands, sshConfig); + const results = await processCommands(commands, sshConfig, bastionHost); if (results.status === 'success') { - console.log('Command executed successfully'); + // console.log('Command executed successfully'); const parser = new xml2js.Parser({ explicitArray: false }); // Consider setting explicitArray to false to simplify the structure const facts = {}; let rpcReply; for (const result of results.data) { - // console.log(`result: ${JSON.stringify(result, null, 2)}\n\n`); - rpcReply = getRpcReply('system-information', result); if (rpcReply !== null) { @@ -272,44 +561,51 @@ export const getDeviceFacts = async (address, port, username, password, timeout, const v = await parser.parseStringPromise(rpcReply); const vcMembers = v['virtual-chassis-information']['member-list']['member'].map((member) => ({ - model: member['member-model'], - serial: member['member-serial-number'], - slot: member['member-id'], - role: member['member-role'], - })) - + model: member['member-model'], + serial: member['member-serial-number'], + slot: member['member-id'], + role: member['member-role'], + })); + facts.vc = vcMembers; } } - // console.log(`>>>facts: ${JSON.stringify(facts, null, 2)}`); - // Validate gathered facts - const missingInfo = ['systemInformation'].filter( - (info) => !facts[info] - ); + const missingInfo = ['systemInformation', 'chassisInventory'].filter((info) => !facts[info]); if (missingInfo.length) { console.error(`Missing data: ${missingInfo.join(', ')}`); - throw StatusErrorMessages.NO_RPC_REPLY; + throw { + ...StatusErrorMessages.SSH_CLIENT_ERROR, + message: `Rpc reply missing (${missingInfo.join(', ')})`, + }; } facts.status = 'success'; - console.log('facts:', JSON.stringify(facts, null, 2)); - return facts; } else { - console.error(`getDeviceFacts Error type 1: status: ${results.status} message: ${results.message}`); + console.error( + `getDeviceFacts Error(${address}:${port}) type 1: status: ${results.status} message: ${results.message}` + ); throw results; } } catch (error) { - console.error(`getDeviceFacts Error message: "${error.message}"`); - throw error; // Rethrow the error after logging it + console.error(`getDeviceFacts Error(${address}:${port}): ${JSON.stringify(error)}`); + throw error; } }; -export const commitJunosSetConfig = async (address, port, username, password, config, readyTimeout = 10000) => { +export const commitJunosSetConfig = async ( + address, + port, + username, + password, + config, + bastionHost = {}, + readyTimeout = 10000 +) => { const configs = config .trim() .split(/\n/) @@ -327,7 +623,7 @@ export const commitJunosSetConfig = async (address, port, username, password, co }; try { - const results = await processCommands(commands, sshConfig); + const results = await processCommands(commands, sshConfig, bastionHost); if (results.status === 'success') { let commitReply = null; @@ -353,11 +649,13 @@ export const commitJunosSetConfig = async (address, port, username, password, co throw StatusErrorMessages.COMMIT_ERROR; } else { - console.error(`getDeviceFacts Error type 1: status: ${results.status} message: ${results.message}`); + console.error( + `commitJunosSetConfig Error(${address}:${port}) type 1: status: ${results.status} message: ${results.message}` + ); throw results; } } catch (error) { - console.error(`getDeviceFacts Error message: "${error.message}"`); - throw error; // Rethrow the error after logging it + console.error(`commitJunosSetConfig Error(${address}:${port}): ${JSON.stringify(error)}`); + throw error; } }; diff --git a/jccm/src/Services/mainStore.js b/jccm/src/Services/mainStore.js index 46171e8..a2a1399 100644 --- a/jccm/src/Services/mainStore.js +++ b/jccm/src/Services/mainStore.js @@ -2,6 +2,7 @@ import { app } from 'electron'; import path from 'path'; import Datastore from 'nedb-promises'; import { getActiveThemeName } from '../Frontend/Common/CommonVariables'; +import { isBase64 } from 'validator'; // You might need to install the 'validator' package // Define the path to the database file const dbPath = path.join(app.getPath('userData'), 'sessionDB.db'); @@ -177,12 +178,30 @@ export const msSetOrgFilter = async (orgFilter) => { }; export const msSetLocalInventory = async (inventory) => { - await db.update({ _id: localInventoryKey }, { _id: localInventoryKey, data: inventory }, { upsert: true }); + const encodedInventory = encodeToBase64(inventory); + + await db.update( + { _id: localInventoryKey }, + { $set: { data: encodedInventory } }, // Use $set to ensure the data field is replaced, not merged + { upsert: true } + ); }; export const msGetLocalInventory = async () => { const doc = await db.findOne({ _id: localInventoryKey }); - return doc ? doc.data : []; + + if (doc && doc.data) { + if (typeof doc.data === 'string') { + // Check if the data is a Base64 encoded string + if (isBase64(doc.data)) { + return decodeFromBase64(doc.data); + } + } else { + // Data is presumably already an object or array + return doc.data; + } + } + return []; }; export const msSaveDeviceFacts = async (facts) => { diff --git a/jccm/src/main.js b/jccm/src/main.js index 6c73ad0..930d5de 100644 --- a/jccm/src/main.js +++ b/jccm/src/main.js @@ -1,5 +1,4 @@ import { app, BrowserWindow, screen, dialog } from 'electron'; -import { initializeDatabase } from './Services/mainStore'; import { setupApiHandlers } from './Services/ApiServer'; // Import the API handlers import path from 'path'; import os from 'os'; diff --git a/readme.md b/readme.md index 44d95e6..da265b0 100644 --- a/readme.md +++ b/readme.md @@ -15,6 +15,26 @@ Juniper Cloud Connection Manager (JCCM) is a standalone application designed to - **Multi-Platform Support:** Available for both Intel-based and ARM-based macOS systems and Intel-based Windows systems. - **Social Login Support:** Google Social SSO Login is currently in the tech preview stage. - **Network Search Support:** Network subnet search to generate an inventory file is in the tech preview stage. +- **Bastion Host Proxy Support** + +## Bastion Host Proxy Support + +- **Overview:** This feature enables access to target devices through a bastion host proxy, commonly referred to as a jump server. +- **Supported Systems:** Currently, Linux machines equipped with OpenSSH server/client are supported for use as the bastion host proxy. + +### Note on Junos Machines + +- While Junos machines are technically capable of functioning as bastion hosts, they are not recommended due to performance limitations and potential adverse effects on regular operations. + +## Usage + +To configure a Linux machine as your bastion host proxy, ensure that the OpenSSH server/client is properly set up and accessible. For guidance on configuring OpenSSH, refer to the [OpenSSH documentation](https://www.openssh.com/manual.html). + +### Performance Considerations + +- **Linux Machines:** Optimal for use as bastion hosts due to their robust handling of SSH connections and minimal impact on device performance. +- **Junos Machines:** Should be avoided as bastion hosts where possible to prevent degradation in the machine's core functionalities and overall performance. + ## Device Adoption Demo @@ -23,6 +43,9 @@ Juniper Cloud Connection Manager (JCCM) is a standalone application designed to ## Network Search Demo ![JCCM Network Search Demo](./demo/jccm-network-search.gif) +## Bastion Host Proxy Demo +![JCCM Network Search Demo](./demo/jccm-bastionhost.gif) + ## Installation ### Download