Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bc5f4d6ad |
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user