import { execFile } from "child_process";
import { promisify } from "util";
import { HostConfig } from "../types.js";
const execFileAsync = promisify(execFile);
/**
* Sanitize string for safe shell usage
* Rejects any potentially dangerous characters
*/
function sanitizeForShell(input: string): string {
// Only allow alphanumeric, dots, hyphens, underscores, and forward slashes (for paths)
if (!/^[a-zA-Z0-9._\-\/]+$/.test(input)) {
throw new Error(`Invalid characters in input: ${input}`);
}
return input;
}
/**
* Validate host configuration for SSH
*/
function validateHostForSsh(host: HostConfig): void {
// Validate hostname/IP - allow alphanumeric, dots, hyphens, colons (IPv6), and brackets
if (host.host && !/^[a-zA-Z0-9.\-:\[\]\/]+$/.test(host.host)) {
throw new Error(`Invalid host format: ${host.host}`);
}
// Validate SSH user if provided
if (host.sshUser && !/^[a-zA-Z0-9_\-]+$/.test(host.sshUser)) {
throw new Error(`Invalid SSH user: ${host.sshUser}`);
}
// Validate key path if provided
if (host.sshKeyPath && !/^[a-zA-Z0-9._\-\/~]+$/.test(host.sshKeyPath)) {
throw new Error(`Invalid SSH key path: ${host.sshKeyPath}`);
}
}
/**
* Host resource stats from SSH
*/
export interface HostResources {
hostname: string;
uptime: string;
loadAverage: [number, number, number];
cpu: {
cores: number;
usagePercent: number;
};
memory: {
totalMB: number;
usedMB: number;
freeMB: number;
usagePercent: number;
};
disk: Array<{
filesystem: string;
mount: string;
totalGB: number;
usedGB: number;
availGB: number;
usagePercent: number;
}>;
}
/**
* Build SSH command for a host (uses execFile-style array for safety)
*/
function buildSshArgs(host: HostConfig): string[] {
// Validate all inputs first
validateHostForSsh(host);
const args = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=accept-new"];
if (host.sshKeyPath) {
args.push("-i", sanitizeForShell(host.sshKeyPath));
}
const user = host.sshUser ? sanitizeForShell(host.sshUser) : "root";
const target = host.host.includes("/") ? "localhost" : sanitizeForShell(host.host);
args.push(`${user}@${target}`);
return args;
}
/**
* Execute SSH command on a host using execFile for safety
*/
async function sshExec(host: HostConfig, command: string): Promise<string> {
const args = buildSshArgs(host);
// Command is passed as final argument - it's a static script, not user input
args.push(command);
try {
const { stdout } = await execFileAsync("ssh", args, { timeout: 15000 });
return stdout.trim();
} catch (error) {
throw new Error(`SSH failed: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
/**
* Get host resource usage via SSH
*/
export async function getHostResources(host: HostConfig): Promise<HostResources> {
// Run all commands in one SSH session for efficiency
const script = `
hostname
echo "---"
uptime -p 2>/dev/null || uptime | sed 's/.*up/up/'
echo "---"
cat /proc/loadavg | awk '{print $1,$2,$3}'
echo "---"
nproc
echo "---"
top -bn1 | grep "Cpu(s)" | awk '{print 100-$8}' 2>/dev/null || echo "0"
echo "---"
free -m | awk '/^Mem:/ {print $2,$3,$4}'
echo "---"
df -BG --output=source,target,size,used,avail,pcent 2>/dev/null | grep -E '^/dev' || df -h | grep -E '^/dev'
`.trim().replace(/\n/g, "; ");
const output = await sshExec(host, script);
const sections = output.split("---").map(s => s.trim());
// Parse hostname
const hostname = sections[0] || host.name;
// Parse uptime
const uptime = sections[1] || "unknown";
// Parse load average
const loadParts = (sections[2] || "0 0 0").split(" ").map(Number);
const loadAverage: [number, number, number] = [
loadParts[0] || 0,
loadParts[1] || 0,
loadParts[2] || 0
];
// Parse CPU
const cores = parseInt(sections[3] || "1", 10);
const cpuUsage = parseFloat(sections[4] || "0");
// Parse memory
const memParts = (sections[5] || "0 0 0").split(" ").map(Number);
const totalMB = memParts[0] || 0;
const usedMB = memParts[1] || 0;
const freeMB = memParts[2] || 0;
const memUsagePercent = totalMB > 0 ? (usedMB / totalMB) * 100 : 0;
// Parse disk
const diskLines = (sections[6] || "").split("\n").filter(l => l.trim());
const disk = diskLines.map(line => {
const parts = line.trim().split(/\s+/);
if (parts.length >= 6) {
return {
filesystem: parts[0],
mount: parts[1],
totalGB: parseFloat(parts[2].replace("G", "")) || 0,
usedGB: parseFloat(parts[3].replace("G", "")) || 0,
availGB: parseFloat(parts[4].replace("G", "")) || 0,
usagePercent: parseFloat(parts[5].replace("%", "")) || 0
};
}
return null;
}).filter((d): d is NonNullable<typeof d> => d !== null);
return {
hostname,
uptime,
loadAverage,
cpu: {
cores,
usagePercent: Math.round(cpuUsage * 10) / 10
},
memory: {
totalMB,
usedMB,
freeMB,
usagePercent: Math.round(memUsagePercent * 10) / 10
},
disk
};
}