Compare commits

..

1 Commits

Author SHA1 Message Date
Michel Rögl-Brunner
4bc5f4d6ad fix: handle special characters in SSH password/passphrase (Fixes #312)
- Use sshpass -f with temp file in transferScriptsFolder so password/passphrase
  never go through shell; safe for {, $, ", etc.
- Pass password via SSH_PASSWORD env in testWithExpect instead of embedding
  in script
- Add ServerForm hint: SSH key recommended; special chars supported
2026-01-29 15:18:41 +01:00
5 changed files with 113 additions and 93 deletions

View File

@@ -1153,11 +1153,10 @@ class ScriptExecutionHandler {
const hostname = hostnames[i];
try {
// Read config file to get hostname/name (node-specific path)
const nodeName = server.name;
// Read config file to get hostname/name
const configPath = containerType === 'lxc'
? `/etc/pve/nodes/${nodeName}/lxc/${nextId}.conf`
: `/etc/pve/nodes/${nodeName}/qemu-server/${nextId}.conf`;
? `/etc/pve/lxc/${nextId}.conf`
: `/etc/pve/qemu-server/${nextId}.conf`;
let configContent = '';
await new Promise(/** @type {(resolve: (value?: void) => void) => void} */ ((resolve) => {

View File

@@ -438,6 +438,11 @@ export function ServerForm({
{errors.password && (
<p className="text-destructive mt-1 text-sm">{errors.password}</p>
)}
<p className="text-muted-foreground mt-1 text-xs">
SSH key is recommended when possible. Special characters (e.g.{" "}
<code className="rounded bg-muted px-0.5">{"{ } $ \" '"}</code>) are
supported.
</p>
</div>
)}

View File

@@ -418,46 +418,44 @@ async function isVM(scriptId: number, containerId: string, serverId: number | nu
return false; // Default to LXC if SSH fails
}
// Node-specific paths (multi-node Proxmox: /etc/pve/nodes/NODENAME/...)
const nodeName = (server as Server).name;
const vmConfigPathNode = `/etc/pve/nodes/${nodeName}/qemu-server/${containerId}.conf`;
const lxcConfigPathNode = `/etc/pve/nodes/${nodeName}/lxc/${containerId}.conf`;
// Fallback for single-node or when server.name is not the Proxmox node name
const vmConfigPathFallback = `/etc/pve/qemu-server/${containerId}.conf`;
const lxcConfigPathFallback = `/etc/pve/lxc/${containerId}.conf`;
const checkPathExists = (path: string): Promise<boolean> =>
new Promise<boolean>((resolve) => {
let exists = false;
void sshExecutionService.executeCommand(
server as Server,
`test -f "${path}" && echo "exists" || echo "not_exists"`,
(data: string) => {
if (data.includes('exists')) exists = true;
},
() => resolve(exists),
() => resolve(exists)
);
});
// Prefer node-specific paths first
const vmConfigExistsNode = await checkPathExists(vmConfigPathNode);
if (vmConfigExistsNode) {
return true; // VM config file exists on node
// Check both config file paths
const vmConfigPath = `/etc/pve/qemu-server/${containerId}.conf`;
const lxcConfigPath = `/etc/pve/lxc/${containerId}.conf`;
// Check VM config file
let vmConfigExists = false;
await new Promise<void>((resolve) => {
void sshExecutionService.executeCommand(
server as Server,
`test -f "${vmConfigPath}" && echo "exists" || echo "not_exists"`,
(data: string) => {
if (data.includes('exists')) {
vmConfigExists = true;
}
},
() => resolve(),
() => resolve()
);
});
if (vmConfigExists) {
return true; // VM config file exists
}
// Check LXC config file (not needed for return value, but check for completeness)
await new Promise<void>((resolve) => {
void sshExecutionService.executeCommand(
server as Server,
`test -f "${lxcConfigPath}" && echo "exists" || echo "not_exists"`,
(_data: string) => {
// Data handler not needed - just checking if file exists
},
() => resolve(),
() => resolve()
);
});
const lxcConfigExistsNode = await checkPathExists(lxcConfigPathNode);
if (lxcConfigExistsNode) {
return false; // LXC config file exists on node
}
// Fallback: single-node or server.name not matching Proxmox node name
const vmConfigExistsFallback = await checkPathExists(vmConfigPathFallback);
if (vmConfigExistsFallback) {
return true;
}
return false; // LXC (or neither path exists)
return false; // Always LXC since VM config doesn't exist
} catch (error) {
console.error('Error determining container type:', error);
return false; // Default to LXC on error
@@ -973,11 +971,10 @@ export const installedScriptsRouter = createTRPCRouter({
};
// Helper function to check config file for community-script tag and extract hostname/name
const nodeName = (server as Server).name;
const checkConfigAndExtractInfo = async (id: string, isVM: boolean): Promise<any> => {
const configPath = isVM
? `/etc/pve/nodes/${nodeName}/qemu-server/${id}.conf`
: `/etc/pve/nodes/${nodeName}/lxc/${id}.conf`;
? `/etc/pve/qemu-server/${id}.conf`
: `/etc/pve/lxc/${id}.conf`;
const readCommand = `cat "${configPath}" 2>/dev/null`;
@@ -1321,10 +1318,10 @@ export const installedScriptsRouter = createTRPCRouter({
// Check if ID exists in either pct list (containers) or qm list (VMs)
if (!existingIds.has(containerId)) {
// Also verify config file doesn't exist as a double-check (node-specific paths)
const nodeName = (server as Server).name;
const checkContainerCommand = `test -f "/etc/pve/nodes/${nodeName}/lxc/${containerId}.conf" && echo "exists" || echo "not_found"`;
const checkVMCommand = `test -f "/etc/pve/nodes/${nodeName}/qemu-server/${containerId}.conf" && echo "exists" || echo "not_found"`;
// Also verify config file doesn't exist as a double-check
// Check both container and VM config paths
const checkContainerCommand = `test -f "/etc/pve/lxc/${containerId}.conf" && echo "exists" || echo "not_found"`;
const checkVMCommand = `test -f "/etc/pve/qemu-server/${containerId}.conf" && echo "exists" || echo "not_found"`;
const configExists = await new Promise<boolean>((resolve) => {
let combinedOutput = '';
@@ -2240,9 +2237,8 @@ export const installedScriptsRouter = createTRPCRouter({
};
}
// Read config file (node-specific path)
const nodeName = (server as Server).name;
const configPath = `/etc/pve/nodes/${nodeName}/lxc/${script.container_id}.conf`;
// Read config file
const configPath = `/etc/pve/lxc/${script.container_id}.conf`;
const readCommand = `cat "${configPath}" 2>/dev/null`;
let rawConfig = '';
@@ -2372,9 +2368,8 @@ export const installedScriptsRouter = createTRPCRouter({
};
}
// Write config file using heredoc for safe escaping (node-specific path)
const nodeName = (server as Server).name;
const configPath = `/etc/pve/nodes/${nodeName}/lxc/${script.container_id}.conf`;
// Write config file using heredoc for safe escaping
const configPath = `/etc/pve/lxc/${script.container_id}.conf`;
const writeCommand = `cat > "${configPath}" << 'EOFCONFIG'
${rawConfig}
EOFCONFIG`;
@@ -2782,10 +2777,9 @@ EOFCONFIG`;
const { getSSHExecutionService } = await import('~/server/ssh-execution-service');
const sshExecutionService = getSSHExecutionService();
const nodeName = (server as Server).name;
const configPath = input.containerType === 'lxc'
? `/etc/pve/nodes/${nodeName}/lxc/${input.containerId}.conf`
: `/etc/pve/nodes/${nodeName}/qemu-server/${input.containerId}.conf`;
? `/etc/pve/lxc/${input.containerId}.conf`
: `/etc/pve/qemu-server/${input.containerId}.conf`;
let configContent = '';
await new Promise<void>((resolve) => {
@@ -3177,11 +3171,10 @@ EOFCONFIG`;
const { getSSHExecutionService } = await import('~/server/ssh-execution-service');
const sshExecutionService = getSSHExecutionService();
// Read config file to get hostname/name (node-specific path)
const nodeName = (server as Server).name;
// Read config file to get hostname/name
const configPath = input.containerType === 'lxc'
? `/etc/pve/nodes/${nodeName}/lxc/${input.containerId}.conf`
: `/etc/pve/nodes/${nodeName}/qemu-server/${input.containerId}.conf`;
? `/etc/pve/lxc/${input.containerId}.conf`
: `/etc/pve/qemu-server/${input.containerId}.conf`;
let configContent = '';
await new Promise<void>((resolve) => {

View File

@@ -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);
}
});

View File

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