mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-13 18:46:20 +05:00
572 lines
13 KiB
TypeScript
572 lines
13 KiB
TypeScript
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<typeof setTimeout> | 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<void> => {
|
|
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));
|
|
};
|