UNPKG

@underpostnet/underpost

Version:

Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc

957 lines (863 loc) 39.4 kB
/** * SSH module for managing SSH key generation and connection setup. * @module src/cli/ssh.js * @namespace UnderpostSSH */ import { generateRandomPasswordSelection } from '../client/components/core/CommonJs.js'; import { pbcopy, shellExec } from '../server/process.js'; import { loggerFactory } from '../server/logger.js'; import fs from 'fs-extra'; import Underpost from '../index.js'; const logger = loggerFactory(import.meta); /** * @class UnderpostSSH * @description Manages SSH key generation and connection setup. * @memberof UnderpostSSH */ class UnderpostSSH { static API = { /** * Loads configuration node from disk or returns default empty config. * @method * @function loadConfigNode * @memberof UnderpostSSH * @param {string} deployId - Deployment ID for the config path * @returns {{confNode: Object, confNodePath: string}} Configuration node and its file path * @description Loads or creates a config node with users object structure */ loadConfigNode: (deployId) => { const confNodePath = `./engine-private/conf/${deployId}/conf.node.json`; const confNode = fs.existsSync(confNodePath) ? JSON.parse(fs.readFileSync(confNodePath, 'utf8')) : { users: {} }; return { confNode, confNodePath }; }, /** * Saves configuration node to disk. * @method * @function saveConfigNode * @memberof UnderpostSSH * @param {string} confNodePath - Path to the configuration file * @param {Object} confNode - Configuration object to save * @returns {void} */ saveConfigNode: (confNodePath, confNode) => { fs.outputFileSync(confNodePath, JSON.stringify(confNode, null, 4), 'utf8'); }, /** * Checks if a system user exists. * @method * @function checkUserExists * @memberof UnderpostSSH * @param {string} username - Username to check * @returns {boolean} True if user exists, false otherwise */ checkUserExists: (username) => { const result = shellExec(`id -u ${username} 2>/dev/null || echo "not_found"`, { silent: true, stdout: true, }).trim(); return result !== 'not_found'; }, /** * Gets the home directory for a given user. * @method * @function getUserHome * @memberof UnderpostSSH * @param {string} username - Username to get home directory for * @returns {string} User's home directory path */ getUserHome: (username) => { return shellExec(`getent passwd ${username} | cut -d: -f6`, { silent: true, stdout: true, }).trim(); }, /** * Creates a system user with password and groups. * @method * @function createSystemUser * @memberof UnderpostSSH * @param {string} username - Username to create * @param {string} password - Password for the user * @param {string} groups - Comma-separated list of groups * @returns {void} */ createSystemUser: (username, password, groups) => { shellExec(`useradd -m -s /bin/bash ${username}`); shellExec(`echo "${username}:${password}" | chpasswd`); if (groups) { for (const group of groups.split(',').map((g) => g.trim())) { shellExec(`usermod -aG "${group}" "${username}"`); } } }, /** * Ensures SSH directory exists with proper permissions. * @method * @function ensureSSHDirectory * @memberof UnderpostSSH * @param {string} sshDir - Path to SSH directory * @returns {void} */ ensureSSHDirectory: (sshDir) => { if (!fs.existsSync(sshDir)) { shellExec(`mkdir -p ${sshDir}`); shellExec(`chmod 700 ${sshDir}`); } }, /** * Sets proper permissions on SSH files. * @method * @function setSSHFilePermissions * @memberof UnderpostSSH * @param {string} sshDir - SSH directory path * @param {string} username - Username for ownership * @param {string} [keyPath] - Optional private key path * @param {string} [pubKeyPath] - Optional public key path * @returns {void} */ setSSHFilePermissions: (sshDir, username, keyPath, pubKeyPath) => { shellExec(`chmod 600 ${sshDir}/authorized_keys`); shellExec(`chmod 644 ${sshDir}/known_hosts`); if (keyPath) shellExec(`chmod 600 ${keyPath}`); if (pubKeyPath) shellExec(`chmod 644 ${pubKeyPath}`); shellExec(`chown -R ${username}:${username} ${sshDir}`); }, /** * Configures authorized_keys for a user. * @method * @function configureAuthorizedKeys * @memberof UnderpostSSH * @param {string} sshDir - SSH directory path * @param {string} pubKeyPath - Public key file path * @param {boolean} disablePassword - Whether to add no-forwarding restrictions * @returns {void} */ configureAuthorizedKeys: (sshDir, pubKeyPath, disablePassword) => { if (disablePassword) { shellExec(`cat >> ${sshDir}/authorized_keys <<EOF no-port-forwarding,no-X11-forwarding,no-agent-forwarding ${fs.readFileSync(pubKeyPath, 'utf8')} EOF`); } else { shellExec(`cat ${pubKeyPath} >> ${sshDir}/authorized_keys`); } }, /** * Configures known_hosts with SSH server keys. * @method * @function configureKnownHosts * @memberof UnderpostSSH * @param {string} sshDir - SSH directory path * @param {number} port - SSH port number * @param {string} [host] - Optional external host to add * @returns {void} */ configureKnownHosts: (sshDir, port, host) => { shellExec(`ssh-keyscan -p ${port} -H localhost >> ${sshDir}/known_hosts`); shellExec(`ssh-keyscan -p ${port} -H 127.0.0.1 >> ${sshDir}/known_hosts`); if (host) shellExec(`ssh-keyscan -p ${port} -H ${host} >> ${sshDir}/known_hosts`); }, /** * Configures sudoers for passwordless sudo or sets user password. * @method * @function configureSudoAccess * @memberof UnderpostSSH * @param {string} username - Username to configure * @param {string} password - User password * @param {boolean} disablePassword - Whether to enable passwordless sudo * @returns {void} */ configureSudoAccess: (username, password, disablePassword) => { if (disablePassword) { shellExec(`echo '${username} ALL=(ALL) NOPASSWD: ALL' | sudo tee /etc/sudoers.d/90_${username}`); } else { shellExec(`echo "${username}:${password}" | sudo chpasswd`); } }, /** * Main callback function for SSH operations including user management, key import/export, and SSH service configuration. * @async * @function callback * @memberof UnderpostSSH * @param {Object} options - Configuration options for SSH operations * @param {string} [options.deployId=''] - Deployment ID context for SSH operations * @param {boolean} [options.generate=false] - Generate new SSH credentials * @param {string} [options.user=''] - SSH user name (defaults to 'root') * @param {string} [options.password=''] - SSH user password (auto-generated if not provided, overridden by saved config if user exists) * @param {string} [options.host=''] - SSH host address (defaults to public IP, overridden by saved config if user exists) * @param {string} [options.filter=''] - Filter for user/group listings * @param {string} [options.groups=''] - Comma-separated list of groups for the user (defaults to 'wheel') * @param {number} [options.port=22] - SSH port number * @param {boolean} [options.start=false] - Start SSH service with hardened configuration * @param {boolean} [options.userAdd=false] - Add a new SSH user and generate keys * @param {boolean} [options.userRemove=false] - Remove an SSH user and cleanup keys * @param {boolean} [options.userLs=false] - List all SSH users and groups * @param {boolean} [options.reset=false] - Reset SSH configuration (clear authorized_keys and known_hosts) * @param {boolean} [options.keysList=false] - List authorized SSH keys * @param {boolean} [options.hostsList=false] - List known SSH hosts * @param {boolean} [options.disablePassword=false] - If true, enable passwordless sudo and add SSH restrictions * @param {boolean} [options.keyTest=false] - Test SSH key generation * @param {boolean} [options.stop=false] - Stop SSH service * @param {boolean} [options.status=false] - Check SSH service status * @param {boolean} [options.connectUri=false] - Output SSH connection URI * @param {boolean} [options.copy=false] - Copy SSH connection URI to clipboard * @returns {Promise<void>} * @description * Handles various SSH operations: * - User creation with automatic key generation and backup * - User removal with key cleanup * - Key import/export between SSH directory and private backup location * - SSH service initialization and hardening * - User and group listing with optional filtering */ callback: async ( options = { deployId: '', generate: false, user: '', password: '', host: '', filter: '', groups: '', port: 22, start: false, userAdd: false, userRemove: false, userLs: false, reset: false, keysList: false, hostsList: false, disablePassword: false, keyTest: false, stop: false, status: false, connectUri: false, copy: false, }, ) => { let confNode, confNodePath; // Set defaults if (!options.user) options.user = 'root'; if (!options.host) options.host = await Underpost.dns.getPublicIp(); if (!options.password) options.password = options.disablePassword ? '' : generateRandomPasswordSelection(16); if (!options.groups) options.groups = 'wheel'; if (!options.port) options.port = 22; // Handle connect uri const userHome = Underpost.ssh.getUserHome(options.user); options.userHome = userHome; // Load config and override password and host if user exists in config if (options.deployId) { const config = Underpost.ssh.loadConfigNode(options.deployId); confNode = config.confNode; confNodePath = config.confNodePath; if (confNode.users && confNode.users[options.user]) { if (confNode.users[options.user].host) { options.host = confNode.users[options.user].host; logger.info(`Using saved host for user ${options.user}: ${options.host}`); } if (confNode.users[options.user].password === '') { options.disablePassword = true; options.password = ''; logger.info(`Using saved empty password for user ${options.user}`); } else if (confNode.users[options.user].password) { options.disablePassword = false; options.password = confNode.users[options.user].password; logger.info(`Using saved password for user ${options.user}`); } options.port = options.port || confNode.users[options.user].port || 22; } } logger.info('options', options); // Handle connect uri if (options.connectUri) { const keyPath = `${userHome}/.ssh/id_rsa`; const uri = `ssh ${options.user}@${options.host} -i ${keyPath} -p ${options.port}`; if (options.copy) { pbcopy(uri); } else console.log(uri); return; } // Handle reset operation if (options.reset) { shellExec(`> ${userHome}/.ssh/authorized_keys`); shellExec(`> ${userHome}/.ssh/known_hosts`); } if (options.userLs) { const filter = options.filter ? `${options.filter}` : ''; const groupsOut = shellExec(`getent group${filter ? ` | grep '${filter}'` : ''}`, { silent: true, stdout: true, }); const usersOut = shellExec(`getent passwd${filter ? ` | grep '${filter}'` : ''}`, { silent: true, stdout: true, }); console.log('Groups'.bold.blue); console.log(`group_name : password_x : GID(Internal Group ID) : user_list`.blue); console.log(filter ? groupsOut.replaceAll(filter, filter.red) : groupsOut); console.log('Users'.bold.blue); console.log(`user : x : UID : GID : GECOS : home_dir : shell`.blue); console.log(filter ? usersOut.replaceAll(filter, filter.red) : usersOut); } // Handle user removal (works with or without deployId) if (options.userRemove) { const groups = shellExec(`id -Gn ${options.user}`, { silent: true, stdout: true }).trim().replace(/ /g, ', '); shellExec(`userdel -r ${options.user}`); // Remove sudoers file if it exists const sudoersFile = `/etc/sudoers.d/90_${options.user}`; if (fs.existsSync(sudoersFile)) { shellExec(`sudo rm -f ${sudoersFile}`); logger.info(`Sudoers file removed: ${sudoersFile}`); } // Remove the private key copy folder and update config only if deployId is provided if (options.deployId) { if (!confNode) { const config = Underpost.ssh.loadConfigNode(options.deployId); confNode = config.confNode; confNodePath = config.confNodePath; } const privateCopyDir = `./engine-private/conf/${options.deployId}/users/${options.user}`; if (fs.existsSync(privateCopyDir)) { fs.removeSync(privateCopyDir); logger.info(`Private key copy removed from ${privateCopyDir}`); } delete confNode.users[options.user]; Underpost.ssh.saveConfigNode(confNodePath, confNode); } logger.info(`User removed`); if (groups) logger.info(`User removed from groups: ${groups}`); return; } // Handle user addition (works with or without deployId) if (options.userAdd) { let privateCopyDir, privateKeyPath, publicKeyPath, keysExistInBackup, userExistsInConfig; // If deployId is provided, check for existing config and backup keys if (options.deployId) { if (!confNode) { const config = Underpost.ssh.loadConfigNode(options.deployId); confNode = config.confNode; confNodePath = config.confNodePath; } userExistsInConfig = confNode.users && confNode.users[options.user]; privateCopyDir = `./engine-private/conf/${options.deployId}/users/${options.user}`; privateKeyPath = `${privateCopyDir}/id_rsa`; publicKeyPath = `${privateCopyDir}/id_rsa.pub`; keysExistInBackup = fs.existsSync(privateKeyPath) && fs.existsSync(publicKeyPath); // If user exists in config AND keys exist in backup, import those keys if (userExistsInConfig && keysExistInBackup) { logger.info(`User ${options.user} already exists in config. Importing existing keys...`); // Create system user if it doesn't exist const userExists = Underpost.ssh.checkUserExists(options.user); if (!userExists) { Underpost.ssh.createSystemUser(options.user, options.password, options.groups); } const userHome = Underpost.ssh.getUserHome(options.user); const sshDir = `${userHome}/.ssh`; Underpost.ssh.ensureSSHDirectory(sshDir); const userKeyPath = `${sshDir}/id_rsa`; const userPubKeyPath = `${sshDir}/id_rsa.pub`; // Import keys from backup fs.copyFileSync(privateKeyPath, userKeyPath); fs.copyFileSync(publicKeyPath, userPubKeyPath); Underpost.ssh.configureAuthorizedKeys(sshDir, userPubKeyPath, options.disablePassword); Underpost.ssh.configureSudoAccess(options.user, options.password, options.disablePassword); Underpost.ssh.configureKnownHosts(sshDir, options.port, options.host); Underpost.ssh.setSSHFilePermissions(sshDir, options.user, userKeyPath, userPubKeyPath); logger.info(`Keys imported from ${privateCopyDir} to ${sshDir}`); logger.info(`User added with existing keys`); return; } } // New user or no existing keys - create new user and generate keys Underpost.ssh.createSystemUser(options.user, options.password, options.groups); const userHome = Underpost.ssh.getUserHome(options.user); const sshDir = `${userHome}/.ssh`; Underpost.ssh.ensureSSHDirectory(sshDir); const keyPath = `${sshDir}/id_rsa`; const pubKeyPath = `${sshDir}/id_rsa.pub`; if (!fs.existsSync(keyPath)) { shellExec( `ssh-keygen -t ed25519 -f ${keyPath} -N "${options.password}" -q -C "${options.user}@${options.host}"`, ); } Underpost.ssh.configureAuthorizedKeys(sshDir, pubKeyPath, options.disablePassword); Underpost.ssh.configureSudoAccess(options.user, options.password, options.disablePassword); Underpost.ssh.configureKnownHosts(sshDir, options.port, options.host); Underpost.ssh.setSSHFilePermissions(sshDir, options.user, keyPath, pubKeyPath); // Save a copy of the keys to the private folder only if deployId is provided if (options.deployId) { if (!privateCopyDir) privateCopyDir = `./engine-private/conf/${options.deployId}/users/${options.user}`; fs.ensureDirSync(privateCopyDir); const privateKeyCopyPath = `${privateCopyDir}/id_rsa`; const publicKeyCopyPath = `${privateCopyDir}/id_rsa.pub`; fs.copyFileSync(keyPath, privateKeyCopyPath); fs.copyFileSync(pubKeyPath, publicKeyCopyPath); logger.info(`Keys backed up to ${privateCopyDir}`); confNode.users[options.user] = { ...confNode.users[options.user], ...options, keyPath, pubKeyPath, privateKeyCopyPath, publicKeyCopyPath, }; Underpost.ssh.saveConfigNode(confNodePath, confNode); } logger.info(`User added`); return; } // Handle config user listing (only with deployId) if (options.deployId) { if (!confNode) { const config = Underpost.ssh.loadConfigNode(options.deployId); confNode = config.confNode; confNodePath = config.confNodePath; } if (options.userLs && confNode && confNode.users) { logger.info(`Users in config:`); Object.keys(confNode.users).forEach((user) => { logger.info(`- ${user}`); }); } } // Handle generate root keys if (options.generate) Underpost.ssh.generateKeys({ user: options.user, password: options.password, host: options.host }); // Handle list operations if (options.keysList) shellExec(`cat ${userHome}/.ssh/authorized_keys`); if (options.hostsList) shellExec(`cat ${userHome}/.ssh/known_hosts`); // Handle key test if (options.keyTest) { const keyPath = `${userHome}/.ssh/id_rsa`; shellExec(`ssh-keygen -y -f ${keyPath} -P "${options.password}"`); } // Handle stop server if (options.stop) shellExec('service sshd stop'); // Handle start server if (options.start) { Underpost.ssh.chmod({ user: options.user }); Underpost.ssh.initService({ port: options.port }); } // Handle status server if (options.status) shellExec('service sshd status'); }, /** * Synchronously copies a local directory to a remote host over key-only SSH, * streaming it as a tar archive (no intermediate file) and fixing ownership. * Mirrors the kind-node `tar | docker cp` provisioning pattern but targets a * real node via SSH, so node-local hostPath volumes can be materialized on the * node where the pod will actually run. * * Idempotent and re-runnable: `mkdir -p` + `tar -x` overwrite in place. Throws * on any SSH/tar failure so an empty-volume deploy is never produced silently. * * @function copyDirToNode * @memberof UnderpostSSH * @param {object} params * @param {string} params.host - Target host/IP (key-only SSH reachable). * @param {string} params.localDir - Local source directory. * @param {string} params.remoteDir - Destination directory on the node. * @param {number} [params.port=22] - SSH port. * @param {string} [params.user='root'] - SSH user (key-only). * @param {string} [params.keyPath='./engine-private/deploy/id_rsa'] - Private key path. * @param {string} [params.owner='1000:1000'] - chown target on the node (empty to skip). * @param {string} [params.mode='755'] - chmod mode on the node (empty to skip). * @returns {void} */ copyDirToNode: ({ host, localDir, remoteDir, port = 22, user = 'root', keyPath = './engine-private/deploy/id_rsa', owner = '1000:1000', mode = '755', }) => { if (!host) throw new Error('copyDirToNode requires a host'); if (!localDir || !fs.existsSync(localDir)) throw new Error(`copyDirToNode: local dir not found: ${localDir}`); if (!remoteDir) throw new Error('copyDirToNode requires a remoteDir'); shellExec(`chmod 600 ${keyPath}`, { silent: true, silentOnError: true, disableLog: true }); const sshOpts = `-i ${keyPath} -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p ${port}`; shellExec(`ssh ${sshOpts} ${user}@${host} 'mkdir -p ${remoteDir}'`); shellExec(`tar -C ${localDir} -c . | ssh ${sshOpts} ${user}@${host} 'tar -C ${remoteDir} -x'`); const fixups = `${owner ? `chown -R ${owner} ${remoteDir}; ` : ''}${mode ? `chmod -R ${mode} ${remoteDir}` : ''}`.trim(); if (fixups) shellExec(`ssh ${sshOpts} ${user}@${host} '${fixups}'`); }, /** * Generic SSH remote command runner that SSH execution logic. * Executes arbitrary shell commands on a remote server via SSH with proper credential handling. * @async * @function sshRemoteRunner * @param {string} remoteCommand - The command to execute on the remote server * @param {Object} options - Configuration options for SSH execution * @param {string} [options.deployId] - Deployment ID for credential lookup * @param {string} [options.user] - SSH user for credential lookup * @param {boolean} [options.dev=false] - Development mode flag * @param {string} [options.cd='/home/dd/engine'] - Working directory on remote server * @param {boolean} [options.useSudo=true] - Whether to use sudo for command execution * @param {boolean} [options.remote=true] - Whether to execute as remote command (if false, runs locally) * @returns {Promise<string>} Output from the shell execution * @memberof UnderpostSSH */ sshRemoteRunner: async (remoteCommand, options = {}) => { const { deployId = '', user = '', dev = false, cd = '/home/dd/engine', useSudo = true, remote = true } = options; // If not executing remotely, just run locally if (!remote) { return shellExec(remoteCommand); } // Set up SSH credentials from config if (deployId && user) { await Underpost.ssh.setDefautlSshCredentials({ deployId, user }); } // Build the complete SSH command const sshScript = `#!/usr/bin/env bash set -euo pipefail REMOTE_USER=$(node bin config get --plain DEFAULT_SSH_USER) REMOTE_HOST=$(node bin config get --plain DEFAULT_SSH_HOST) REMOTE_PORT=$(node bin config get --plain DEFAULT_SSH_PORT) SSH_KEY=$(node bin config get --plain DEFAULT_SSH_KEY_PATH) chmod 600 "$SSH_KEY" ssh -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$REMOTE_USER@$REMOTE_HOST" -p $REMOTE_PORT sh <<EOF ${cd ? `cd ${cd}` : ''} ${useSudo ? `sudo -n -- /bin/bash -lc "${remoteCommand}"` : remoteCommand} EOF `; return shellExec(sshScript, { stdout: true }); }, /** * Waits until a TCP SSH port becomes reachable on a host. * @async * @function waitForSshPort * @memberof UnderpostSSH * @param {object} params * @param {string} params.host - Target host/IP. * @param {number} [params.port=22] - SSH port. * @param {number} [params.timeoutMs=600000] - Maximum wait window. * @param {number} [params.intervalMs=3000] - Poll interval. * @returns {Promise<boolean>} True once the port accepts connections, false on timeout. */ waitForSshPort: async ({ host, port = 22, timeoutMs = 10 * 60 * 1000, intervalMs = 3000 }) => { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const probe = shellExec( `timeout 5 bash -c '</dev/tcp/${host}/${port}' >/dev/null 2>&1 && echo open || echo closed`, { silent: true, stdout: true, silentOnError: true, disableLog: true }, ); if (`${probe}`.trim() === 'open') return true; await new Promise((r) => setTimeout(r, intervalMs)); } logger.warn(`SSH port ${host}:${port} not reachable within timeout`); return false; }, /** * Waits until a host's SSH port stops accepting connections (e.g. while it * reboots). Used to detect a reboot edge before waiting for the port to come * back up, so callers don't latch onto the pre-reboot (ephemeral) sshd. * @async * @function waitForSshPortClosed * @memberof UnderpostSSH * @param {object} params * @param {string} params.host - Target host/IP. * @param {number} [params.port=22] - SSH port. * @param {number} [params.timeoutMs=180000] - Maximum wait window. * @param {number} [params.intervalMs=3000] - Poll interval. * @returns {Promise<boolean>} True once the port is closed, false on timeout. */ waitForSshPortClosed: async ({ host, port = 22, timeoutMs = 3 * 60 * 1000, intervalMs = 3000 }) => { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const probe = shellExec( `timeout 5 bash -c '</dev/tcp/${host}/${port}' >/dev/null 2>&1 && echo open || echo closed`, { silent: true, stdout: true, silentOnError: true, disableLog: true }, ); if (`${probe}`.trim() === 'closed') return true; await new Promise((r) => setTimeout(r, intervalMs)); } return false; }, /** * Orchestrates a non-interactive, key-only SSH session against a freshly * provisioned host: waits for the port, attempts key-based auth, runs a * remote command batch, and returns a structured result. Used by the * commissioning flow once the ephemeral runtime reports SSH readiness. * @async * @function sshExecBatch * @memberof UnderpostSSH * @param {object} params * @param {string} params.host - Target host/IP. * @param {string} params.command - Remote command batch to execute. * @param {number} [params.port=22] - SSH port. * @param {string} [params.user='root'] - SSH user (key-only). * @param {string} [params.keyPath] - Private key path (defaults to engine deploy key). * @param {number} [params.connectTimeoutSec=15] - Per-attempt SSH connect timeout. * @param {number} [params.retries=3] - Auth/exec retry attempts. * @param {number} [params.retryDelayMs=5000] - Base backoff between retries. * @param {number} [params.waitForPortMs=0] - When > 0, wait for the port first. * @returns {Promise<{ok: boolean, code: number, stdout: string, stderr: string, attempts: number}>} */ sshExecBatch: async ({ host, command, port = 22, user = 'root', keyPath = './engine-private/deploy/id_rsa', connectTimeoutSec = 15, retries = 3, retryDelayMs = 5000, waitForPortMs = 0, }) => { if (!host) throw new Error('sshExecBatch requires a host'); if (!command) throw new Error('sshExecBatch requires a command'); if (waitForPortMs > 0) { const reachable = await Underpost.ssh.waitForSshPort({ host, port, timeoutMs: waitForPortMs }); if (!reachable) return { ok: false, code: 255, stdout: '', stderr: 'ssh port unreachable', attempts: 0 }; } shellExec(`chmod 600 ${keyPath}`, { silent: true, silentOnError: true, disableLog: true }); const sshOpts = [ `-i ${keyPath}`, `-o BatchMode=yes`, `-o PreferredAuthentications=publickey`, `-o PubkeyAuthentication=yes`, `-o PasswordAuthentication=no`, `-o StrictHostKeyChecking=no`, `-o UserKnownHostsFile=/dev/null`, `-o ConnectTimeout=${connectTimeoutSec}`, // Tolerate a freshly-booted node whose network briefly flaps (e.g. while // NetworkManager applies a static profile): retry the TCP connect and // keep the session alive across short stalls. `-o ConnectionAttempts=3`, `-o ServerAliveInterval=10`, `-o ServerAliveCountMax=6`, `-p ${port}`, ].join(' '); let last = { ok: false, code: 255, stdout: '', stderr: '', attempts: 0 }; for (let attempt = 1; attempt <= retries; attempt++) { const result = shellExec(`ssh ${sshOpts} ${user}@${host} bash -s <<'UNDERPOST_SSH_BATCH_EOF'\n${command}\nUNDERPOST_SSH_BATCH_EOF`, { stdout: false, silentOnError: true, disableLog: true, }); last = { ok: result.code === 0, code: result.code, stdout: result.stdout || '', stderr: result.stderr || '', attempts: attempt, }; if (last.ok) { logger.info(`sshExecBatch succeeded on ${user}@${host}:${port} (attempt ${attempt})`); return last; } logger.warn(`sshExecBatch attempt ${attempt}/${retries} failed on ${user}@${host}:${port}`, { code: last.code, stderr: last.stderr.slice(-400), }); if (attempt < retries) await new Promise((r) => setTimeout(r, retryDelayMs * attempt)); } return last; }, /** * Transfers a local script to a remote host and runs it over key-only SSH. * The script is base64-encoded so no shell-quoting/escaping is needed, then * decoded, made executable, and executed with the given arguments. Reuses * sshExecBatch for the actual transport, retries, and structured result. * @async * @function sshRunScript * @memberof UnderpostSSH * @param {object} params * @param {string} params.host - Target host/IP. * @param {string} params.scriptPath - Local path to the script to run. * @param {string} [params.args=''] - Arguments appended to the remote invocation. * @param {object} [params.env={}] - Environment variables exported for the remote run (e.g. secrets). Passed inline to the command, never echoed. * @param {string} [params.remotePath='/tmp/underpost-remote-script.sh'] - Remote path to write the script. * @param {number} [params.port=22] - SSH port. * @param {string} [params.user='root'] - SSH user (key-only). * @param {string} [params.keyPath] - Private key path (defaults to engine deploy key). * @param {number} [params.retries=3] - Retry attempts. * @param {number} [params.waitForPortMs=0] - When > 0, wait for the port first. * @returns {Promise<{ok: boolean, code: number, stdout: string, stderr: string, attempts: number}>} */ sshRunScript: async ({ host, scriptPath, args = '', env = {}, remotePath = '/tmp/underpost-remote-script.sh', port = 22, user = 'root', keyPath = './engine-private/deploy/id_rsa', retries = 3, waitForPortMs = 0, }) => { if (!fs.existsSync(scriptPath)) throw new Error(`sshRunScript: script not found: ${scriptPath}`); const b64 = Buffer.from(fs.readFileSync(scriptPath, 'utf8'), 'utf8').toString('base64'); // Inline env assignments (single-quote escaped) so secrets are exported for // the remote run without appearing as logged CLI args. const sq = (v) => `'${String(v).replace(/'/g, "'\\''")}'`; const envPrefix = Object.entries(env) .filter(([, v]) => v !== undefined && v !== null && `${v}` !== '') .map(([k, v]) => `${k}=${sq(v)}`) .join(' '); const command = [ 'set -e', `echo '${b64}' | base64 -d > ${remotePath}`, `chmod +x ${remotePath}`, `${envPrefix ? `${envPrefix} ` : ''}bash ${remotePath} ${args}`, ].join('\n'); return Underpost.ssh.sshExecBatch({ host, port, user, keyPath, retries, waitForPortMs, command }); }, /** * Loads saved SSH credentials from config and sets them in the UnderpostRootEnv API. * @async * @function setDefautlSshCredentials * @memberof UnderpostSSH * @param {Object} options - Options for setting default SSH credentials * @param {string} options.deployId - Deployment ID for the config path * @param {string} options.user - SSH user name * @returns {Promise<void>} */ setDefautlSshCredentials: async (options = { deployId: '', user: '' }) => { const confNodePath = `./engine-private/conf/${options.deployId}/conf.node.json`; if (fs.existsSync(confNodePath)) { const { users } = JSON.parse(fs.readFileSync(confNodePath, 'utf8')); const { user, host, keyPath, port } = users[options.user]; Underpost.env.set('DEFAULT_SSH_USER', user); Underpost.env.set('DEFAULT_SSH_HOST', host); Underpost.env.set('DEFAULT_SSH_KEY_PATH', keyPath); Underpost.env.set('DEFAULT_SSH_PORT', port); } else logger.warn(`No SSH config found at ${confNodePath}`); }, /** * Generates new SSH ED25519 key pair and stores copies in multiple locations. * @function generateKeys * @memberof UnderpostSSH * @param {Object} params - Key generation parameters * @param {string} params.user - Username for the SSH key comment * @param {string} params.password - Password to encrypt the private key * @param {string} params.host - Host address for the SSH key comment * @returns {void} * @description * Creates a new SSH ED25519 key pair and distributes it to: * - User's ~/.ssh/ directory * - ./engine-private/deploy/ directory * Cleans up temporary key files after copying. */ generateKeys: ({ user, password, host }) => { shellExec(`sudo rm -rf ./id_rsa`); shellExec(`sudo rm -rf ./id_rsa.pub`); shellExec(`ssh-keygen -t ed25519 -f id_rsa -N "${password}" -q -C "${user}@${host}"`); shellExec(`sudo cp ./id_rsa ~/.ssh/id_rsa`); shellExec(`sudo cp ./id_rsa.pub ~/.ssh/id_rsa.pub`); shellExec(`sudo cp ./id_rsa ./engine-private/deploy/id_rsa`); shellExec(`sudo cp ./id_rsa.pub ./engine-private/deploy/id_rsa.pub`); shellExec(`sudo rm -rf ./id_rsa`); shellExec(`sudo rm -rf ./id_rsa.pub`); }, /** * Sets proper permissions and ownership for SSH directories and files. * @function chmod * @memberof UnderpostSSH * @param {Object} params - Permission configuration parameters * @param {string} params.user - Username for setting ownership * @returns {void} * @description * Applies secure permissions to SSH files: * - ~/.ssh/ directory: 700 * - ~/.ssh/authorized_keys: 600 * - ~/.ssh/known_hosts: 644 * - ~/.ssh/id_rsa: 600 * - /etc/ssh/ssh_host_ed25519_key: 600 * Sets ownership to specified user for ~/.ssh/ and contents. */ chmod: ({ user }) => { shellExec(`sudo chmod 700 ~/.ssh/`); shellExec(`sudo chmod 600 ~/.ssh/authorized_keys`); shellExec(`sudo chmod 644 ~/.ssh/known_hosts`); shellExec(`sudo chmod 600 ~/.ssh/id_rsa`); shellExec(`sudo chmod 600 /etc/ssh/ssh_host_ed25519_key`); shellExec(`chown -R ${user}:${user} ~/.ssh`); }, /** * Initializes and hardens SSH service configuration for RHEL-based systems. * @function initService * @memberof UnderpostSSH * @param {Object} params - Service configuration parameters * @param {number} params.port - Port number for SSH service * @returns {void} * @description * Configures SSH daemon with hardened security settings: * - Disables password authentication (key-only) * - Disables root login * - Enables ED25519 host key * - Disables X11 forwarding and TCP forwarding * - Sets client alive intervals to prevent ghost connections * - Configures PAM for RHEL/SELinux compatibility * * After configuration: * - Enables sshd service for auto-start on boot * - Restarts sshd service to apply changes * - Displays service status with colored output */ initService: ({ port }) => { shellExec( `sudo tee /etc/ssh/sshd_config <<EOF # ============================================================== # RHEL Hardened SSHD Configuration # ============================================================== # --- Network Settings --- Port ${port ? port : '22'} # Explicitly listen on all interfaces (IPv4 and IPv6) # --- Host Keys --- # ED25519 is the modern standard (Fast & Secure) HostKey /etc/ssh/ssh_host_ed25519_key # RSA is kept for compatibility with older clients (Optional) # HostKey /etc/ssh/ssh_host_rsa_key # --- Logging --- SyslogFacility AUTHPRIV # VERBOSE logs the key fingerprint used for login (Audit trail) LogLevel VERBOSE # --- Authentication & Security --- # STRICTLY KEY-BASED AUTHENTICATION PubkeyAuthentication yes PasswordAuthentication no ChallengeResponseAuthentication no PermitEmptyPasswords no # PREVENT ROOT LOGIN # Administrators should log in as a standard user and use 'sudo' PermitRootLogin no # Security checks on ownership of ~/.ssh/ files StrictModes yes MaxAuthTries 3 LoginGraceTime 60 # --- PAM (Pluggable Authentication Modules) --- # REQUIRED: Must be 'yes' on RHEL for proper session/SELinux handling. # Since PasswordAuthentication is 'no', PAM will not ask for passwords. UsePAM yes # --- Session & Network Health --- # Disconnect idle sessions after 5 minutes (300s * 0) to prevent ghost connections ClientAliveInterval 300 ClientAliveCountMax 0 # --- Feature Restrictions --- # Disable GUI forwarding unless explicitly needed X11Forwarding no # Disable DNS checks for faster logins (unless you use Host based auth) UseDNS no # Disable tunneling unless needed PermitTunnel no AllowTcpForwarding no # --- Subsystem --- Subsystem sftp /usr/libexec/openssh/sftp-server EOF`, { disableLog: true }, ); shellExec(`sudo systemctl enable sshd`); shellExec(`sudo systemctl restart sshd`); const status = shellExec(`sudo systemctl status sshd`, { silent: true, stdout: true }); if (status.match('running')) console.log(status.replaceAll(`running`, `running`.green)); else { logger.error('SSHD service failed to start'); console.log(status); } }, }; } export default UnderpostSSH;