import { type ChildProcess, exec, execFile, spawn } from "node:child_process"; import util from "node:util"; import { findServerById } from "@dokploy/server/services/server"; import { Client, type ClientChannel } from "ssh2"; import { ExecError } from "./ExecError"; import { withRemoteDeadline } from "./remote-deadline"; export class WriteFileRemoteError extends Error { constructor( message: string, public readonly context: { remotePath: string; serverId: string; originalError: Error; }, ) { super(message); this.name = "WriteFileRemoteError"; } } // Re-export ExecError for easier imports export { ExecError } from "./ExecError"; const execAsyncBase = util.promisify(exec); export interface ExecAsyncOptions { cwd?: string; env?: NodeJS.ProcessEnv; shell?: string; timeout?: number; } export interface ExecAsyncRemoteOptions { timeout?: number; } const killProcessGroup = (child: ChildProcess) => { if (child.pid == null) return; if (process.platform === "win32") { child.kill("SIGKILL"); return; } try { process.kill(-child.pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch { // already exited } } }; const execAsyncTimed = ( command: string, options: ExecAsyncOptions & { timeout: number }, ): Promise<{ stdout: string; stderr: string }> => { const { timeout, cwd, env, shell } = options; const isWin = process.platform === "win32"; const shellPath = shell ?? (isWin ? process.env.ComSpec || "cmd.exe" : "/bin/sh"); const args = isWin ? ["/d", "/s", "/c", command] : ["-c", command]; return new Promise((resolve, reject) => { let stdout = ""; let stderr = ""; let settled = false; const child = spawn(shellPath, args, { cwd, env, detached: !isWin, stdio: ["ignore", "pipe", "pipe"], }); const timer = setTimeout(() => { killProcessGroup(child); finish( new ExecError(`Command execution timed out after ${timeout}ms`, { command, stdout, stderr, }), ); }, timeout); const finish = (error?: ExecError) => { if (settled) return; settled = true; clearTimeout(timer); if (error) reject(error); else resolve({ stdout, stderr }); }; child.stdout?.on("data", (data) => { stdout += data.toString(); }); child.stderr?.on("data", (data) => { stderr += data.toString(); }); child.on("error", (error) => { finish( new ExecError(`Command execution failed: ${error.message}`, { command, stdout, stderr, originalError: error, }), ); }); child.on("close", (code) => { if (code === 0) { finish(); return; } finish( new ExecError(`Command execution failed: Command failed: ${command}`, { command, stdout, stderr, exitCode: code ?? undefined, }), ); }); }); }; export const execAsync = async ( command: string, options?: ExecAsyncOptions, ): Promise<{ stdout: string; stderr: string }> => { const timeout = options?.timeout; if (timeout != null && Number.isFinite(timeout) && timeout > 0) { return execAsyncTimed(command, { ...options, timeout }); } const { timeout: _timeout, ...execOptions } = options ?? {}; try { const result = await execAsyncBase(command, execOptions); return { stdout: result.stdout.toString(), stderr: result.stderr.toString(), }; } catch (error) { if (error instanceof Error) { // @ts-expect-error - exec error has these properties const exitCode = error.code; // @ts-expect-error const stdout = error.stdout?.toString() || ""; // @ts-expect-error const stderr = error.stderr?.toString() || ""; throw new ExecError(`Command execution failed: ${error.message}`, { command, stdout, stderr, exitCode, originalError: error, }); } throw error; } }; interface ExecOptions { cwd?: string; env?: NodeJS.ProcessEnv; } export const execAsyncStream = ( command: string, onData?: (data: string) => void, options: ExecOptions = {}, ): Promise<{ stdout: string; stderr: string }> => { return new Promise((resolve, reject) => { let stdoutComplete = ""; let stderrComplete = ""; const childProcess = exec(command, options, (error) => { if (error) { reject( new ExecError(`Command execution failed: ${error.message}`, { command, stdout: stdoutComplete, stderr: stderrComplete, exitCode: error.code, originalError: error, }), ); return; } resolve({ stdout: stdoutComplete, stderr: stderrComplete }); }); childProcess.stdout?.on("data", (data: Buffer | string) => { const stringData = data.toString(); stdoutComplete += stringData; if (onData) { onData(stringData); } }); childProcess.stderr?.on("data", (data: Buffer | string) => { const stringData = data.toString(); stderrComplete += stringData; if (onData) { onData(stringData); } }); childProcess.on("error", (error) => { console.log(error); reject( new ExecError(`Command execution error: ${error.message}`, { command, stdout: stdoutComplete, stderr: stderrComplete, originalError: error, }), ); }); }); }; export const execFileAsync = async ( command: string, args: string[], options: { input?: string } = {}, ): Promise<{ stdout: string; stderr: string }> => { const child = execFile(command, args); if (options.input && child.stdin) { child.stdin.write(options.input); child.stdin.end(); } return new Promise((resolve, reject) => { let stdout = ""; let stderr = ""; child.stdout?.on("data", (data) => { stdout += data.toString(); }); child.stderr?.on("data", (data) => { stderr += data.toString(); }); child.on("close", (code) => { if (code === 0) { resolve({ stdout, stderr }); } else { reject( new Error(`Command failed with code ${code}. Stderr: ${stderr}`), ); } }); child.on("error", reject); }); }; export const execAsyncRemote = async ( serverId: string | null, command: string, onData?: (data: string) => void, options?: ExecAsyncRemoteOptions, ): Promise<{ stdout: string; stderr: string }> => { if (!serverId) return { stdout: "", stderr: "" }; const server = await findServerById(serverId); if (!server.sshKeyId) throw new Error("No SSH key available for this server"); let stdout = ""; let stderr = ""; const requestedTimeout = options?.timeout; const timeoutMs = requestedTimeout != null && Number.isFinite(requestedTimeout) && requestedTimeout > 0 ? requestedTimeout : undefined; const started = Date.now(); return new Promise((resolve, reject) => { const conn = new Client(); let stream: ClientChannel | undefined; let settled = false; let timer: ReturnType | undefined; const closeStream = (channel?: ClientChannel) => { if (timeoutMs !== undefined) { try { channel?.signal("KILL"); } catch { /* remote watchdog owns cleanup */ } } try { channel?.close(); } catch { /* already closed */ } try { channel?.destroy(); } catch { /* already destroyed */ } }; const settle = ( error?: ExecError, value?: { stdout: string; stderr: string }, ) => { if (settled) return; settled = true; if (timer !== undefined) { clearTimeout(timer); timer = undefined; } if (error) { closeStream(stream); try { conn.end(); } catch { // already ended } try { conn.destroy(); } catch { // already destroyed } reject(error); return; } try { conn.end(); } catch { // already ended } resolve(value ?? { stdout, stderr }); }; if (timeoutMs != null && timeoutMs > 0) { timer = setTimeout(() => { settle( new ExecError(`Command execution timed out after ${timeoutMs}ms`, { command, stdout, stderr, serverId, }), ); }, timeoutMs); } try { conn .once("ready", () => { if (settled) return; const remoteCommand = timeoutMs === undefined ? command : withRemoteDeadline( command, Math.max(1, timeoutMs - (Date.now() - started)), ); try { conn.exec(remoteCommand, (err, commandStream) => { if (settled) { closeStream(commandStream); return; } if (err) { onData?.(err.message); settle( new ExecError( `Remote command execution failed: ${err.message}`, { command, serverId, originalError: err, }, ), ); return; } stream = commandStream; commandStream .on("error", (error: Error) => { if (settled) return; settle( new ExecError( `Remote command stream failed: ${error.message}`, { command, stdout, stderr, serverId, originalError: error, }, ), ); }) .on("close", (code: number, _signal: string) => { if (code === 0) { settle(undefined, { stdout, stderr }); } else { settle( new ExecError( `Remote command failed with exit code ${code}`, { command, stdout, stderr, exitCode: code, serverId, }, ), ); } }) .on("data", (data: string) => { if (settled) return; stdout += data.toString(); onData?.(data.toString()); }) .stderr.on("data", (data) => { if (settled) return; stderr += data.toString(); onData?.(data.toString()); }); }); } catch (error) { settle( new ExecError("Remote command execution failed", { command, stdout, stderr, serverId, originalError: error instanceof Error ? error : undefined, }), ); } }) .on("error", (err) => { if (settled) return; if (err.level === "client-authentication") { const technicalDetail = `Error: ${err.message} ${err.level}`; const friendlyMessage = [ "", "❌ Couldn't connect to your server — the SSH key was not accepted.", "", "This usually means the key doesn't match what's on the server, or the key format is invalid.", "", `Technical details: ${technicalDetail}`, "", "💡 Hints:", " • Check that the SSH key you added in Dokploy is the same one installed on the server (e.g. in ~/.ssh/authorized_keys).", " • Try generating a new SSH key in Dokploy and add only the public key to the server, then try again.", " • Make sure to follow the instructions on the Setup Server Button on the SSH Keys tab and then click on deployments tab and check the logs for more details.", ].join("\n"); onData?.(friendlyMessage); settle( new ExecError( `Authentication failed: Invalid SSH private key. ${friendlyMessage}`, { command, serverId, originalError: err, }, ), ); } else { const errorMsg = `SSH connection error: ${err.message}`; onData?.(errorMsg); settle( new ExecError(errorMsg, { stdout, stderr, command, serverId, originalError: err, }), ); } }) .connect({ host: server.ipAddress, port: server.port, username: server.username, privateKey: server.sshKey?.privateKey, timeout: timeoutMs != null && timeoutMs > 0 ? timeoutMs : 99999, ...(timeoutMs != null && timeoutMs > 0 ? { readyTimeout: timeoutMs } : {}), }); } catch (error) { settle( new ExecError("SSH connection failed", { command, stdout, stderr, serverId, originalError: error instanceof Error ? error : undefined, }), ); } }); }; export const writeFileRemote = async ( serverId: string, remotePath: string, content: string, ): Promise => { const server = await findServerById(serverId); if (!server.sshKeyId) throw new Error("No SSH key available for this server"); return new Promise((resolve, reject) => { const conn = new Client(); conn .once("ready", () => { conn.sftp((err, sftp) => { if (err) { conn.end(); reject( new WriteFileRemoteError(`SFTP session failed: ${err.message}`, { remotePath, serverId, originalError: err, }), ); return; } sftp.writeFile(remotePath, content, (writeErr) => { conn.end(); if (writeErr) { reject( new WriteFileRemoteError( `Failed to write remote file ${remotePath}: ${writeErr.message}`, { remotePath, serverId, originalError: writeErr }, ), ); return; } resolve(); }); }); }) .on("error", (err) => { conn.end(); reject( new WriteFileRemoteError(`SSH connection error: ${err.message}`, { remotePath, serverId, originalError: err, }), ); }) .connect({ host: server.ipAddress, port: server.port, username: server.username, privateKey: server.sshKey?.privateKey, timeout: 99999, }); }); }; export const sleep = (ms: number) => { return new Promise((resolve) => setTimeout(resolve, ms)); };