diff --git a/src/app/_components/ServerForm.tsx b/src/app/_components/ServerForm.tsx index 5709bf4..a376d83 100644 --- a/src/app/_components/ServerForm.tsx +++ b/src/app/_components/ServerForm.tsx @@ -438,6 +438,11 @@ export function ServerForm({ {errors.password && (

{errors.password}

)} +

+ SSH key is recommended when possible. Special characters (e.g.{" "} + {"{ } $ \" '"}) are + supported. +

)} diff --git a/src/server/ssh-execution-service.js b/src/server/ssh-execution-service.js index 4ed52b4..220310f 100644 --- a/src/server/ssh-execution-service.js +++ b/src/server/ssh-execution-service.js @@ -1,6 +1,8 @@ import { spawn } from 'child_process'; import { spawn as ptySpawn } from 'node-pty'; -import { existsSync } from 'fs'; +import { existsSync, writeFileSync, chmodSync, unlinkSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; /** @@ -194,26 +196,45 @@ class SSHExecutionService { */ async transferScriptsFolder(server, onData, onError) { const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_key_path, ssh_port = 22 } = server; - + + const cleanupTempFile = (/** @type {string | null} */ tempPath) => { + if (tempPath) { + try { + unlinkSync(tempPath); + } catch (_) { + // ignore + } + } + }; + return new Promise((resolve, reject) => { + /** @type {string | null} */ + let tempPath = null; try { - // Build rsync command based on authentication type + // Build rsync command based on authentication type. + // Use sshpass -f with a temp file so password/passphrase never go through the shell (safe for special chars like {, $, "). let rshCommand; if (auth_type === 'key') { if (!ssh_key_path || !existsSync(ssh_key_path)) { throw new Error('SSH key file not found'); } - + if (ssh_key_passphrase) { - rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`; + tempPath = join(tmpdir(), `sshpass-${process.pid}-${Date.now()}.tmp`); + writeFileSync(tempPath, ssh_key_passphrase); + chmodSync(tempPath, 0o600); + rshCommand = `sshpass -P passphrase -f ${tempPath} ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`; } else { rshCommand = `ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`; } } else { // Password authentication - rshCommand = `sshpass -p ${password} ssh -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`; + tempPath = join(tmpdir(), `sshpass-${process.pid}-${Date.now()}.tmp`); + writeFileSync(tempPath, password ?? ''); + chmodSync(tempPath, 0o600); + rshCommand = `sshpass -f ${tempPath} ssh -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`; } - + const rsyncCommand = spawn('rsync', [ '-avz', '--delete', @@ -226,31 +247,31 @@ class SSHExecutionService { stdio: ['pipe', 'pipe', 'pipe'] }); - rsyncCommand.stdout.on('data', (/** @type {Buffer} */ data) => { - // Ensure proper UTF-8 encoding for ANSI colors - const output = data.toString('utf8'); - onData(output); - }); + rsyncCommand.stdout.on('data', (/** @type {Buffer} */ data) => { + const output = data.toString('utf8'); + onData(output); + }); - rsyncCommand.stderr.on('data', (/** @type {Buffer} */ data) => { - // Ensure proper UTF-8 encoding for ANSI colors - const output = data.toString('utf8'); - onError(output); - }); + rsyncCommand.stderr.on('data', (/** @type {Buffer} */ data) => { + const output = data.toString('utf8'); + onError(output); + }); - rsyncCommand.on('close', (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`rsync failed with code ${code}`)); - } - }); + rsyncCommand.on('close', (code) => { + cleanupTempFile(tempPath); + if (code === 0) { + resolve(); + } else { + reject(new Error(`rsync failed with code ${code}`)); + } + }); - rsyncCommand.on('error', (error) => { - reject(error); - }); - + rsyncCommand.on('error', (error) => { + cleanupTempFile(tempPath); + reject(error); + }); } catch (error) { + cleanupTempFile(tempPath); reject(error); } }); diff --git a/src/server/ssh-service.js b/src/server/ssh-service.js index a47df08..400af82 100644 --- a/src/server/ssh-service.js +++ b/src/server/ssh-service.js @@ -169,16 +169,17 @@ class SSHService { const timeout = 10000; let resolved = false; + // Pass password via env so it is not embedded in the script (safe for special chars like {, $, "). const expectScript = `#!/usr/bin/expect -f set timeout 10 spawn ssh -p ${ssh_port} -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=yes -o PubkeyAuthentication=no ${user}@${ip} "echo SSH_LOGIN_SUCCESS" expect { "password:" { - send "${password}\r" + send "$env(SSH_PASSWORD)\\r" exp_continue } "Password:" { - send "${password}\r" + send "$env(SSH_PASSWORD)\\r" exp_continue } "SSH_LOGIN_SUCCESS" { @@ -193,7 +194,8 @@ expect { }`; const expectCommand = spawn('expect', ['-c', expectScript], { - stdio: ['pipe', 'pipe', 'pipe'] + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, SSH_PASSWORD: password ?? '' } }); const timer = setTimeout(() => {