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(() => {