1017 lines
37 KiB
TypeScript
1017 lines
37 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { runApplyPatchV2, type ApplyPatchV2Executor } from "./apply-patch-v2";
|
|
import { type UniDeskConfig } from "./config";
|
|
import { type RemoteCliOptions } from "./remote-options";
|
|
import {
|
|
buildWindowsPowerShellInvocation,
|
|
createPosixApplyPatchFileSystem,
|
|
createWindowsApplyPatchFileSystem,
|
|
createSshStderrForwarder,
|
|
createSshStdoutForwarder,
|
|
formatSshFailureHint,
|
|
formatSshRuntimeTimeoutHint,
|
|
formatSshRuntimeTimingHint,
|
|
normalizeSshOperationArgs,
|
|
parseSshInvocation,
|
|
remoteCommandForRoute,
|
|
sshFailureHint,
|
|
sshRoutePayloadCwd,
|
|
sshRouteSeparatorCompatibilityHint,
|
|
sshRuntimeTimeoutHint,
|
|
sshRuntimeTimeoutMs,
|
|
sshRuntimeTimingHint,
|
|
wrapSshRemoteCommand,
|
|
type SshCaptureResult,
|
|
} from "./ssh";
|
|
import {
|
|
isSshFileTransferOperation,
|
|
runSshFileTransferOperation,
|
|
type SshRemoteCommandExecutor,
|
|
type SshRemoteCommandStreamHandlers,
|
|
} from "./ssh-file-transfer";
|
|
|
|
interface FrontendSession {
|
|
baseUrl: string;
|
|
cookie: string;
|
|
sshClientToken: string | null;
|
|
}
|
|
|
|
interface FetchJsonResult {
|
|
ok: boolean;
|
|
status?: number;
|
|
body?: unknown;
|
|
error?: string;
|
|
responseHeaders?: Record<string, string>;
|
|
responseTruncated?: boolean;
|
|
responseBytesRead?: number;
|
|
responseContentLength?: string | null;
|
|
}
|
|
|
|
const remoteSshInputChunkBytes = 32 * 1024;
|
|
|
|
function normalizeSshCommandArgs(args: string[]): string[] {
|
|
if (args[0] === "ssh") return args;
|
|
return ["ssh", ...args];
|
|
}
|
|
|
|
function shellQuote(value: string): string {
|
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
}
|
|
|
|
function frontendBaseUrl(host: string, config: UniDeskConfig): string {
|
|
if (host.startsWith("http://") || host.startsWith("https://")) return host.replace(/\/+$/u, "");
|
|
if (/:\d+$/u.test(host)) return `http://${host}`;
|
|
return `http://${host}:${config.network.frontend.port}`;
|
|
}
|
|
|
|
function remoteHttpClientMode(env: NodeJS.ProcessEnv = process.env): "curl" | "fetch" {
|
|
const explicit = env.UNIDESK_REMOTE_HTTP_CLIENT?.trim().toLowerCase();
|
|
if (explicit === "fetch") return "fetch";
|
|
if (explicit === "curl") return "curl";
|
|
return isRunnerEnvironment(env) ? "curl" : "fetch";
|
|
}
|
|
|
|
function isRunnerEnvironment(env: NodeJS.ProcessEnv): boolean {
|
|
return Boolean(
|
|
env.AGENTRUN_BOOT_MODE
|
|
|| env.AGENTRUN_RUN_ID
|
|
|| env.AGENTRUN_K8S_JOB_NAME
|
|
|| env.CODE_QUEUE_SERVICE_ROLE
|
|
|| env.CODE_QUEUE_INSTANCE_ID
|
|
|| env.KUBERNETES_SERVICE_HOST,
|
|
);
|
|
}
|
|
|
|
async function readJson(url: string, init?: RequestInit, timeoutMs = 8000, maxResponseBytes = 5_000_000): Promise<FetchJsonResult> {
|
|
if (remoteHttpClientMode() === "curl") return readJsonWithCurl(url, init, timeoutMs, maxResponseBytes);
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
const res = await fetch(url, { ...init, signal: controller.signal });
|
|
const reader = res.body?.getReader();
|
|
const chunks: Uint8Array[] = [];
|
|
let bytes = 0;
|
|
let responseTruncated = false;
|
|
if (reader !== undefined) {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
if (bytes + value.byteLength > maxResponseBytes) {
|
|
const keep = Math.max(0, maxResponseBytes - bytes);
|
|
if (keep > 0) {
|
|
chunks.push(value.slice(0, keep));
|
|
bytes += keep;
|
|
}
|
|
responseTruncated = true;
|
|
try {
|
|
await reader.cancel();
|
|
} catch {
|
|
// Ignore cancel failures after the bounded preview has been collected.
|
|
}
|
|
break;
|
|
}
|
|
chunks.push(value);
|
|
bytes += value.byteLength;
|
|
}
|
|
}
|
|
const buffer = new Uint8Array(bytes);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
buffer.set(chunk, offset);
|
|
offset += chunk.byteLength;
|
|
}
|
|
const text = new TextDecoder().decode(buffer);
|
|
let body: unknown = null;
|
|
try {
|
|
body = text.length > 0 && !responseTruncated ? JSON.parse(text) : null;
|
|
} catch {
|
|
body = { text };
|
|
}
|
|
if (responseTruncated) {
|
|
body = { _unideskResponseTruncated: true, maxResponseBytes, bytesRead: bytes, contentLength: res.headers.get("content-length"), textPreview: text };
|
|
}
|
|
return { ok: res.ok, status: res.status, body, responseHeaders: responseHeadersRecord(res.headers), responseTruncated, responseBytesRead: bytes, responseContentLength: res.headers.get("content-length") };
|
|
} catch (error) {
|
|
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function responseHeadersRecord(headers: Headers): Record<string, string> {
|
|
const record: Record<string, string> = {};
|
|
headers.forEach((value, key) => {
|
|
record[key.toLowerCase()] = value;
|
|
});
|
|
return record;
|
|
}
|
|
|
|
function requestHeaders(init?: RequestInit): Array<[string, string]> {
|
|
const headers = new Headers(init?.headers);
|
|
const output: Array<[string, string]> = [];
|
|
headers.forEach((value, key) => output.push([key, value]));
|
|
return output;
|
|
}
|
|
|
|
async function runCurl(args: string[], timeoutMs: number): Promise<{ status: number | null; stdout: string; stderr: string; timedOut: boolean; error?: string }> {
|
|
const child = spawn("curl", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
const stdoutChunks: Buffer[] = [];
|
|
const stderrChunks: Buffer[] = [];
|
|
let timedOut = false;
|
|
const timer = setTimeout(() => {
|
|
timedOut = true;
|
|
child.kill("SIGTERM");
|
|
}, timeoutMs + 1000);
|
|
child.stdout?.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk)));
|
|
child.stderr?.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk)));
|
|
return await new Promise((resolve) => {
|
|
child.on("error", (error) => {
|
|
clearTimeout(timer);
|
|
resolve({ status: null, stdout: "", stderr: "", timedOut, error: error.message });
|
|
});
|
|
child.on("close", (status) => {
|
|
clearTimeout(timer);
|
|
resolve({
|
|
status,
|
|
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
timedOut,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function parseCurlResponseHeaders(raw: string): Record<string, string> {
|
|
const blocks = raw.split(/\r?\n\r?\n/u).map((block) => block.trim()).filter(Boolean);
|
|
const latest = blocks.at(-1) ?? "";
|
|
const headers: Record<string, string> = {};
|
|
for (const line of latest.split(/\r?\n/u).slice(1)) {
|
|
const splitAt = line.indexOf(":");
|
|
if (splitAt <= 0) continue;
|
|
headers[line.slice(0, splitAt).trim().toLowerCase()] = line.slice(splitAt + 1).trim();
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
function decodeBoundedBody(buffer: Buffer, maxResponseBytes: number): { text: string; truncated: boolean; bytesRead: number } {
|
|
if (buffer.byteLength <= maxResponseBytes) return { text: buffer.toString("utf8"), truncated: false, bytesRead: buffer.byteLength };
|
|
return { text: buffer.subarray(0, maxResponseBytes).toString("utf8"), truncated: true, bytesRead: maxResponseBytes };
|
|
}
|
|
|
|
async function readJsonWithCurl(url: string, init?: RequestInit, timeoutMs = 8000, maxResponseBytes = 5_000_000): Promise<FetchJsonResult> {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "unidesk-remote-http-"));
|
|
const headersFile = path.join(dir, "headers.txt");
|
|
const bodyFile = path.join(dir, "body.bin");
|
|
const requestBodyFile = path.join(dir, "request-body.bin");
|
|
try {
|
|
const method = init?.method ?? (init?.body === undefined ? "GET" : "POST");
|
|
const args = [
|
|
"-sS",
|
|
"--max-time", String(Math.max(1, Math.ceil(timeoutMs / 1000))),
|
|
"-D", headersFile,
|
|
"-o", bodyFile,
|
|
"-w", "%{http_code}",
|
|
"-X", method,
|
|
];
|
|
for (const [key, value] of requestHeaders(init)) args.push("-H", `${key}: ${value}`);
|
|
if (init?.body !== undefined) {
|
|
await writeFile(requestBodyFile, typeof init.body === "string" ? init.body : String(init.body));
|
|
args.push("--data-binary", `@${requestBodyFile}`);
|
|
}
|
|
args.push(url);
|
|
const curl = await runCurl(args, timeoutMs);
|
|
if (curl.status !== 0) {
|
|
const curlError = curl.error ?? curl.stderr.trim();
|
|
return {
|
|
ok: false,
|
|
status: curl.stdout.trim().match(/^\d{3}$/u) ? Number(curl.stdout.trim()) : undefined,
|
|
error: curl.timedOut ? `curl timed out after ${timeoutMs}ms` : (curlError.length > 0 ? curlError : `curl exited with ${curl.status}`),
|
|
};
|
|
}
|
|
const status = Number(curl.stdout.trim());
|
|
const headersText = await readFile(headersFile, "utf8").catch(() => "");
|
|
const responseHeaders = parseCurlResponseHeaders(headersText);
|
|
const bodyBuffer = await readFile(bodyFile).catch(() => Buffer.alloc(0));
|
|
const decoded = decodeBoundedBody(bodyBuffer, maxResponseBytes);
|
|
let body: unknown = null;
|
|
try {
|
|
body = decoded.text.length > 0 && !decoded.truncated ? JSON.parse(decoded.text) : null;
|
|
} catch {
|
|
body = { text: decoded.text };
|
|
}
|
|
if (decoded.truncated) {
|
|
body = {
|
|
_unideskResponseTruncated: true,
|
|
maxResponseBytes,
|
|
bytesRead: decoded.bytesRead,
|
|
contentLength: responseHeaders["content-length"] ?? null,
|
|
textPreview: decoded.text,
|
|
};
|
|
}
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
body,
|
|
responseHeaders,
|
|
responseTruncated: decoded.truncated,
|
|
responseBytesRead: decoded.bytesRead,
|
|
responseContentLength: responseHeaders["content-length"] ?? null,
|
|
};
|
|
} catch (error) {
|
|
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
async function loginFrontend(host: string, config: UniDeskConfig): Promise<FrontendSession> {
|
|
const baseUrl = frontendBaseUrl(host, config);
|
|
const res = await readJson(`${baseUrl}/login`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ username: config.auth.username, password: config.auth.password }),
|
|
}, 8_000, 120_000);
|
|
if (!res.ok) throw new Error(`frontend login failed via ${baseUrl}: status=${res.status ?? "unknown"} body=${JSON.stringify(res.body ?? res.error).slice(0, 300)}`);
|
|
const cookie = res.responseHeaders?.["set-cookie"]?.split(";")[0] ?? "";
|
|
if (cookie.length === 0) throw new Error(`frontend login via ${baseUrl} did not return a session cookie`);
|
|
return { baseUrl, cookie, sshClientToken: null };
|
|
}
|
|
|
|
function sshClientTokenFromEnv(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
const token = env.UNIDESK_SSH_CLIENT_TOKEN?.trim() ?? "";
|
|
return token.length > 0 ? token : null;
|
|
}
|
|
|
|
function scopedSshFrontendSession(host: string, config: UniDeskConfig, token: string): FrontendSession {
|
|
return { baseUrl: frontendBaseUrl(host, config), cookie: "", sshClientToken: token };
|
|
}
|
|
|
|
function frontendSshWebSocketUrl(session: FrontendSession): string {
|
|
const url = new URL("/ws/ssh", session.baseUrl);
|
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
return url.toString();
|
|
}
|
|
|
|
function webSocketDataText(data: unknown): string {
|
|
if (typeof data === "string") return data;
|
|
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
|
|
if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
|
|
return String(data);
|
|
}
|
|
|
|
function openFrontendSshWebSocket(session: FrontendSession): WebSocket {
|
|
const WebSocketWithHeaders = WebSocket as unknown as new (
|
|
url: string,
|
|
options?: { headers?: Record<string, string> },
|
|
) => WebSocket;
|
|
const headers = session.sshClientToken === null
|
|
? { cookie: session.cookie }
|
|
: { authorization: `Bearer ${session.sshClientToken}` };
|
|
return new WebSocketWithHeaders(frontendSshWebSocketUrl(session), { headers });
|
|
}
|
|
|
|
async function runRemoteSshWebSocket(
|
|
session: FrontendSession,
|
|
invocation: ReturnType<typeof parseSshInvocation>,
|
|
): Promise<number> {
|
|
const parsed = invocation.parsed;
|
|
const startedAtMs = Date.now();
|
|
const size = {
|
|
cols: Number(process.stdout.columns) > 0 ? Number(process.stdout.columns) : 100,
|
|
rows: Number(process.stdout.rows) > 0 ? Number(process.stdout.rows) : 30,
|
|
};
|
|
const openTimeoutMs = Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000));
|
|
const runtimeTimeoutMs = sshRuntimeTimeoutMs();
|
|
const payload = {
|
|
providerId: invocation.providerId,
|
|
command: wrapSshRemoteCommand(parsed.remoteCommand, parsed.requiredHelpers),
|
|
cwd: sshRoutePayloadCwd(invocation.route),
|
|
tty: parsed.remoteCommand === null,
|
|
stdinEotOnEnd: parsed.remoteCommand !== null,
|
|
openTimeoutMs,
|
|
runtimeTimeoutMs,
|
|
cols: size.cols,
|
|
rows: size.rows,
|
|
};
|
|
const ws = openFrontendSshWebSocket(session);
|
|
let exitCode = 255;
|
|
let settled = false;
|
|
let canSend = false;
|
|
let sessionReady = false;
|
|
const pending: string[] = [];
|
|
const pendingSessionMessages: string[] = [];
|
|
|
|
const send = (value: unknown): void => {
|
|
const text = JSON.stringify(value);
|
|
if (!canSend || ws.readyState !== WebSocket.OPEN) {
|
|
pending.push(text);
|
|
return;
|
|
}
|
|
ws.send(text);
|
|
};
|
|
const flush = (): void => {
|
|
while (pending.length > 0 && ws.readyState === WebSocket.OPEN) ws.send(pending.shift()!);
|
|
};
|
|
const sendInput = (value: Buffer | string): void => {
|
|
sendWhenSessionReady({ type: "ssh.input", data: Buffer.from(value).toString("base64"), encoding: "base64" });
|
|
};
|
|
const sendInputChunked = (value: string): void => {
|
|
const buffer = Buffer.from(value, "utf8");
|
|
for (let offset = 0; offset < buffer.length; offset += remoteSshInputChunkBytes) {
|
|
sendInput(buffer.subarray(offset, Math.min(buffer.length, offset + remoteSshInputChunkBytes)));
|
|
}
|
|
};
|
|
const sendWhenSessionReady = (value: unknown): void => {
|
|
const text = JSON.stringify(value);
|
|
if (!sessionReady || ws.readyState !== WebSocket.OPEN) {
|
|
pendingSessionMessages.push(text);
|
|
return;
|
|
}
|
|
ws.send(text);
|
|
};
|
|
const flushSessionMessages = (): void => {
|
|
if (!sessionReady || ws.readyState !== WebSocket.OPEN) return;
|
|
while (pendingSessionMessages.length > 0) ws.send(pendingSessionMessages.shift()!);
|
|
};
|
|
|
|
return await new Promise<number>((resolve) => {
|
|
const rawMode = parsed.remoteCommand === null && process.stdin.isTTY && typeof process.stdin.setRawMode === "function";
|
|
const stdoutForwarder = parsed.remoteCommand === null ? null : createSshStdoutForwarder({
|
|
invocation,
|
|
transport: "frontend-websocket",
|
|
});
|
|
const stderrForwarder = parsed.remoteCommand === null ? null : createSshStderrForwarder({
|
|
invocation,
|
|
transport: "frontend-websocket",
|
|
});
|
|
let timedOut = false;
|
|
const openTimer = setTimeout(() => {
|
|
if (sessionReady || settled) return;
|
|
process.stderr.write("unidesk remote frontend ssh bridge timed out waiting for provider session\n");
|
|
exitCode = 255;
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
// Ignore close failures while resolving the timeout path.
|
|
}
|
|
}, openTimeoutMs);
|
|
const runtimeTimer = setTimeout(() => {
|
|
if (settled) return;
|
|
timedOut = true;
|
|
exitCode = 124;
|
|
process.stderr.write(formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({
|
|
invocation,
|
|
transport: "frontend-websocket",
|
|
timeoutMs: runtimeTimeoutMs,
|
|
})));
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
// Ignore close failures while resolving the timeout path.
|
|
}
|
|
finish(124);
|
|
}, runtimeTimeoutMs);
|
|
|
|
const restore = (): void => {
|
|
clearTimeout(openTimer);
|
|
clearTimeout(runtimeTimer);
|
|
process.stdin.off("data", onStdinData);
|
|
process.stdin.off("end", onStdinEnd);
|
|
if (rawMode) process.stdin.setRawMode(false);
|
|
};
|
|
const finish = (code: number): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
restore();
|
|
const hint = timedOut ? null : sshFailureHint(invocation.providerId, parsed, code, "");
|
|
if (hint !== null) process.stderr.write(formatSshFailureHint(hint));
|
|
const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({
|
|
invocation,
|
|
transport: "frontend-websocket",
|
|
exitCode: code,
|
|
startedAtMs,
|
|
}));
|
|
if (timingHint) process.stderr.write(timingHint);
|
|
resolve(code);
|
|
};
|
|
const onStdinData = (chunk: Buffer): void => {
|
|
sendInput(chunk);
|
|
};
|
|
const onStdinEnd = (): void => {
|
|
if (parsed.stdinSuffix) sendInput(parsed.stdinSuffix);
|
|
if (payload.stdinEotOnEnd === true) sendInput(Buffer.from([4]));
|
|
sendWhenSessionReady({ type: "ssh.eof" });
|
|
};
|
|
|
|
ws.addEventListener("open", () => {
|
|
canSend = true;
|
|
send({ type: "ssh.open", ...payload });
|
|
flush();
|
|
});
|
|
ws.addEventListener("message", (event) => {
|
|
const text = webSocketDataText(event.data);
|
|
let message: Record<string, unknown>;
|
|
try {
|
|
message = JSON.parse(text) as Record<string, unknown>;
|
|
} catch {
|
|
process.stderr.write(`${text}\n`);
|
|
return;
|
|
}
|
|
if (message.type === "ssh.dispatched") return;
|
|
if (message.type === "ssh.opened") {
|
|
sessionReady = true;
|
|
clearTimeout(openTimer);
|
|
flushSessionMessages();
|
|
return;
|
|
}
|
|
if (message.type === "ssh.data") {
|
|
const chunk = Buffer.from(String(message.data ?? ""), message.encoding === "base64" ? "base64" : "utf8");
|
|
if (message.stream === "stderr") {
|
|
if (stderrForwarder === null) {
|
|
process.stderr.write(chunk);
|
|
} else {
|
|
const hint = stderrForwarder.write(chunk);
|
|
if (hint !== null) process.stderr.write(hint);
|
|
}
|
|
} else if (stdoutForwarder === null) {
|
|
process.stdout.write(chunk);
|
|
} else {
|
|
const hint = stdoutForwarder.write(chunk);
|
|
if (hint !== null) process.stderr.write(hint);
|
|
}
|
|
return;
|
|
}
|
|
if (message.type === "ssh.error") {
|
|
process.stderr.write(`${String(message.message || "ssh bridge error")}\n`);
|
|
exitCode = 255;
|
|
ws.close();
|
|
return;
|
|
}
|
|
if (message.type === "ssh.exit") {
|
|
exitCode = Number.isInteger(message.exitCode) ? Number(message.exitCode) : 255;
|
|
ws.close();
|
|
}
|
|
});
|
|
ws.addEventListener("close", () => finish(exitCode));
|
|
ws.addEventListener("error", () => {
|
|
process.stderr.write("unidesk remote frontend ssh bridge websocket error\n");
|
|
finish(255);
|
|
});
|
|
|
|
if (rawMode) process.stdin.setRawMode(true);
|
|
process.stdin.resume();
|
|
if (parsed.stdinPrefix) sendInput(parsed.stdinPrefix);
|
|
process.stdin.on("data", onStdinData);
|
|
process.stdin.on("end", onStdinEnd);
|
|
});
|
|
}
|
|
|
|
async function runRemoteSshWebSocketCapture(
|
|
session: FrontendSession,
|
|
invocation: ReturnType<typeof parseSshInvocation>,
|
|
command: string[],
|
|
input?: string,
|
|
): Promise<SshCaptureResult> {
|
|
const remoteCommand = remoteCommandForRoute(invocation.route, command, { stdin: input !== undefined });
|
|
return await runRemoteSshWebSocketCaptureRemoteCommand(session, invocation, remoteCommand, input);
|
|
}
|
|
|
|
async function runRemoteSshWebSocketCaptureRemoteCommand(
|
|
session: FrontendSession,
|
|
invocation: ReturnType<typeof parseSshInvocation>,
|
|
remoteCommand: string,
|
|
input?: string,
|
|
): Promise<SshCaptureResult> {
|
|
const captureInvocation = {
|
|
...invocation,
|
|
parsed: { ...invocation.parsed, remoteCommand, requiresStdin: input !== undefined, invocationKind: "helper" as const },
|
|
};
|
|
const startedAtMs = Date.now();
|
|
const size = {
|
|
cols: Number(process.stdout.columns) > 0 ? Number(process.stdout.columns) : 100,
|
|
rows: Number(process.stdout.rows) > 0 ? Number(process.stdout.rows) : 30,
|
|
};
|
|
const runtimeTimeoutMs = sshRuntimeTimeoutMs();
|
|
const payload = {
|
|
providerId: invocation.providerId,
|
|
command: wrapSshRemoteCommand(remoteCommand),
|
|
cwd: sshRoutePayloadCwd(invocation.route),
|
|
tty: false,
|
|
stdinEotOnEnd: false,
|
|
openTimeoutMs: Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000)),
|
|
runtimeTimeoutMs,
|
|
cols: size.cols,
|
|
rows: size.rows,
|
|
};
|
|
const ws = openFrontendSshWebSocket(session);
|
|
let exitCode = 255;
|
|
let settled = false;
|
|
let canSend = false;
|
|
let sessionReady = false;
|
|
let stdout = "";
|
|
let stderr = "";
|
|
const pending: string[] = [];
|
|
const pendingSessionMessages: string[] = [];
|
|
|
|
const send = (value: unknown): void => {
|
|
const text = JSON.stringify(value);
|
|
if (!canSend || ws.readyState !== WebSocket.OPEN) {
|
|
pending.push(text);
|
|
return;
|
|
}
|
|
ws.send(text);
|
|
};
|
|
const sendWhenSessionReady = (value: unknown): void => {
|
|
const text = JSON.stringify(value);
|
|
if (!sessionReady || ws.readyState !== WebSocket.OPEN) {
|
|
pendingSessionMessages.push(text);
|
|
return;
|
|
}
|
|
ws.send(text);
|
|
};
|
|
const sendInput = (value: Buffer | string): void => {
|
|
sendWhenSessionReady({ type: "ssh.input", data: Buffer.from(value).toString("base64"), encoding: "base64" });
|
|
};
|
|
const sendInputChunked = (value: string): void => {
|
|
const buffer = Buffer.from(value, "utf8");
|
|
for (let offset = 0; offset < buffer.length; offset += remoteSshInputChunkBytes) {
|
|
sendInput(buffer.subarray(offset, Math.min(buffer.length, offset + remoteSshInputChunkBytes)));
|
|
}
|
|
};
|
|
const flush = (): void => {
|
|
while (pending.length > 0 && ws.readyState === WebSocket.OPEN) ws.send(pending.shift()!);
|
|
};
|
|
const flushSessionMessages = (): void => {
|
|
if (!sessionReady || ws.readyState !== WebSocket.OPEN) return;
|
|
while (pendingSessionMessages.length > 0) ws.send(pendingSessionMessages.shift()!);
|
|
};
|
|
|
|
return await new Promise<SshCaptureResult>((resolve) => {
|
|
let killTimer: ReturnType<typeof setTimeout> | null = null;
|
|
const finish = (code: number): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(openTimer);
|
|
clearTimeout(runtimeTimer);
|
|
if (killTimer !== null) clearTimeout(killTimer);
|
|
const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({
|
|
invocation: captureInvocation,
|
|
transport: "frontend-websocket",
|
|
exitCode: code,
|
|
startedAtMs,
|
|
}));
|
|
if (timingHint) stderr += timingHint;
|
|
resolve({ exitCode: code, stdout, stderr });
|
|
};
|
|
const openTimer = setTimeout(() => {
|
|
if (sessionReady || settled) return;
|
|
stderr += "unidesk remote frontend ssh bridge timed out waiting for provider session\n";
|
|
exitCode = 255;
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
// Ignore.
|
|
}
|
|
}, payload.openTimeoutMs);
|
|
const runtimeTimer = setTimeout(() => {
|
|
if (settled) return;
|
|
exitCode = 124;
|
|
stderr += formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({
|
|
invocation: captureInvocation,
|
|
transport: "frontend-websocket",
|
|
timeoutMs: runtimeTimeoutMs,
|
|
}));
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
// Ignore.
|
|
}
|
|
killTimer = setTimeout(() => finish(124), 2000);
|
|
finish(124);
|
|
}, runtimeTimeoutMs);
|
|
|
|
ws.addEventListener("open", () => {
|
|
canSend = true;
|
|
send({ type: "ssh.open", ...payload });
|
|
flush();
|
|
});
|
|
ws.addEventListener("message", (event) => {
|
|
const text = webSocketDataText(event.data);
|
|
let message: Record<string, unknown>;
|
|
try {
|
|
message = JSON.parse(text) as Record<string, unknown>;
|
|
} catch {
|
|
stderr += `${text}\n`;
|
|
return;
|
|
}
|
|
if (message.type === "ssh.dispatched") return;
|
|
if (message.type === "ssh.opened") {
|
|
sessionReady = true;
|
|
clearTimeout(openTimer);
|
|
if (input !== undefined) sendInputChunked(input);
|
|
sendWhenSessionReady({ type: "ssh.eof" });
|
|
flushSessionMessages();
|
|
return;
|
|
}
|
|
if (message.type === "ssh.data") {
|
|
const chunk = Buffer.from(String(message.data ?? ""), message.encoding === "base64" ? "base64" : "utf8").toString("utf8");
|
|
if (message.stream === "stderr") stderr += chunk;
|
|
else stdout += chunk;
|
|
return;
|
|
}
|
|
if (message.type === "ssh.error") {
|
|
stderr += `${String(message.message || "ssh bridge error")}\n`;
|
|
exitCode = 255;
|
|
ws.close();
|
|
return;
|
|
}
|
|
if (message.type === "ssh.exit") {
|
|
exitCode = Number.isInteger(message.exitCode) ? Number(message.exitCode) : 255;
|
|
ws.close();
|
|
}
|
|
});
|
|
ws.addEventListener("close", () => finish(exitCode));
|
|
ws.addEventListener("error", () => {
|
|
stderr += "unidesk remote frontend ssh bridge websocket error\n";
|
|
finish(255);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function runRemoteSshWebSocketStreamRemoteCommand(
|
|
session: FrontendSession,
|
|
invocation: ReturnType<typeof parseSshInvocation>,
|
|
remoteCommand: string,
|
|
handlers: SshRemoteCommandStreamHandlers,
|
|
input?: string,
|
|
options: { inactivityTimeoutMs?: number } = {},
|
|
): Promise<SshCaptureResult> {
|
|
const streamInvocation = {
|
|
...invocation,
|
|
parsed: { ...invocation.parsed, remoteCommand, requiresStdin: input !== undefined, invocationKind: "helper" as const },
|
|
};
|
|
const startedAtMs = Date.now();
|
|
const size = {
|
|
cols: Number(process.stdout.columns) > 0 ? Number(process.stdout.columns) : 100,
|
|
rows: Number(process.stdout.rows) > 0 ? Number(process.stdout.rows) : 30,
|
|
};
|
|
const inactivityTimeoutMs = options.inactivityTimeoutMs ?? sshRuntimeTimeoutMs();
|
|
const runtimeTimeoutMs = options.inactivityTimeoutMs === undefined ? inactivityTimeoutMs : Math.max(inactivityTimeoutMs * 4, 60 * 60_000);
|
|
const payload = {
|
|
providerId: invocation.providerId,
|
|
command: wrapSshRemoteCommand(remoteCommand),
|
|
cwd: sshRoutePayloadCwd(invocation.route),
|
|
tty: false,
|
|
stdinEotOnEnd: false,
|
|
openTimeoutMs: Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000)),
|
|
runtimeTimeoutMs,
|
|
runtimeTimeoutMode: options.inactivityTimeoutMs === undefined ? "wall-clock" : "inactivity",
|
|
cols: size.cols,
|
|
rows: size.rows,
|
|
};
|
|
const ws = openFrontendSshWebSocket(session);
|
|
let exitCode = 255;
|
|
let settled = false;
|
|
let canSend = false;
|
|
let sessionReady = false;
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let streamError: unknown = null;
|
|
let streamWrites = Promise.resolve();
|
|
const pending: string[] = [];
|
|
const pendingSessionMessages: string[] = [];
|
|
|
|
const send = (value: unknown): void => {
|
|
const text = JSON.stringify(value);
|
|
if (!canSend || ws.readyState !== WebSocket.OPEN) {
|
|
pending.push(text);
|
|
return;
|
|
}
|
|
ws.send(text);
|
|
};
|
|
const sendWhenSessionReady = (value: unknown): void => {
|
|
const text = JSON.stringify(value);
|
|
if (!sessionReady || ws.readyState !== WebSocket.OPEN) {
|
|
pendingSessionMessages.push(text);
|
|
return;
|
|
}
|
|
ws.send(text);
|
|
};
|
|
const sendInput = (value: Buffer | string): void => {
|
|
sendWhenSessionReady({ type: "ssh.input", data: Buffer.from(value).toString("base64"), encoding: "base64" });
|
|
};
|
|
const sendInputChunked = (value: string): void => {
|
|
const buffer = Buffer.from(value, "utf8");
|
|
for (let offset = 0; offset < buffer.length; offset += remoteSshInputChunkBytes) {
|
|
sendInput(buffer.subarray(offset, Math.min(buffer.length, offset + remoteSshInputChunkBytes)));
|
|
}
|
|
};
|
|
const flush = (): void => {
|
|
while (pending.length > 0 && ws.readyState === WebSocket.OPEN) ws.send(pending.shift()!);
|
|
};
|
|
const flushSessionMessages = (): void => {
|
|
if (!sessionReady || ws.readyState !== WebSocket.OPEN) return;
|
|
while (pendingSessionMessages.length > 0) ws.send(pendingSessionMessages.shift()!);
|
|
};
|
|
const queueStreamWrite = (chunk: Buffer, stream: "stdout" | "stderr"): void => {
|
|
streamWrites = streamWrites.then(async () => {
|
|
if (stream === "stdout") await handlers.onStdout(chunk);
|
|
else if (handlers.onStderr !== undefined) await handlers.onStderr(chunk);
|
|
}).catch((error) => {
|
|
streamError = error;
|
|
stderr += `unidesk remote frontend ssh stream sink failed: ${error instanceof Error ? error.message : String(error)}\n`;
|
|
exitCode = 255;
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
// Ignore close failures after the local stream sink has failed.
|
|
}
|
|
});
|
|
};
|
|
|
|
return await new Promise<SshCaptureResult>((resolve) => {
|
|
let killTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let inactivityTimer: ReturnType<typeof setTimeout> | null = null;
|
|
const refreshActivityTimer = (): void => {
|
|
if (settled || options.inactivityTimeoutMs === undefined) return;
|
|
if (inactivityTimer !== null) clearTimeout(inactivityTimer);
|
|
inactivityTimer = setTimeout(() => {
|
|
exitCode = 124;
|
|
stderr += formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({
|
|
invocation: streamInvocation,
|
|
transport: "frontend-websocket",
|
|
timeoutMs: inactivityTimeoutMs,
|
|
}));
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
// Ignore.
|
|
}
|
|
killTimer = setTimeout(() => finish(124), 2000);
|
|
finish(124);
|
|
}, inactivityTimeoutMs);
|
|
};
|
|
const finish = (code: number): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(openTimer);
|
|
clearTimeout(runtimeTimer);
|
|
if (inactivityTimer !== null) clearTimeout(inactivityTimer);
|
|
if (killTimer !== null) clearTimeout(killTimer);
|
|
void streamWrites.then(() => {
|
|
const finalCode = streamError === null ? code : 255;
|
|
const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({
|
|
invocation: streamInvocation,
|
|
transport: "frontend-websocket",
|
|
exitCode: finalCode,
|
|
startedAtMs,
|
|
}));
|
|
if (timingHint) stderr += timingHint;
|
|
resolve({ exitCode: finalCode, stdout, stderr });
|
|
});
|
|
};
|
|
const openTimer = setTimeout(() => {
|
|
if (sessionReady || settled) return;
|
|
stderr += "unidesk remote frontend ssh bridge timed out waiting for provider session\n";
|
|
exitCode = 255;
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
// Ignore.
|
|
}
|
|
}, payload.openTimeoutMs);
|
|
const runtimeTimer = setTimeout(() => {
|
|
if (settled) return;
|
|
exitCode = 124;
|
|
stderr += formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({
|
|
invocation: streamInvocation,
|
|
transport: "frontend-websocket",
|
|
timeoutMs: runtimeTimeoutMs,
|
|
}));
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
// Ignore.
|
|
}
|
|
killTimer = setTimeout(() => finish(124), 2000);
|
|
finish(124);
|
|
}, runtimeTimeoutMs);
|
|
refreshActivityTimer();
|
|
|
|
ws.addEventListener("open", () => {
|
|
canSend = true;
|
|
send({ type: "ssh.open", ...payload });
|
|
flush();
|
|
});
|
|
ws.addEventListener("message", (event) => {
|
|
const text = webSocketDataText(event.data);
|
|
let message: Record<string, unknown>;
|
|
try {
|
|
message = JSON.parse(text) as Record<string, unknown>;
|
|
} catch {
|
|
stderr += `${text}\n`;
|
|
return;
|
|
}
|
|
if (message.type === "ssh.dispatched") {
|
|
refreshActivityTimer();
|
|
return;
|
|
}
|
|
if (message.type === "ssh.opened") {
|
|
sessionReady = true;
|
|
clearTimeout(openTimer);
|
|
refreshActivityTimer();
|
|
if (input !== undefined) sendInputChunked(input);
|
|
sendWhenSessionReady({ type: "ssh.eof" });
|
|
flushSessionMessages();
|
|
return;
|
|
}
|
|
if (message.type === "ssh.data") {
|
|
refreshActivityTimer();
|
|
const chunk = Buffer.from(String(message.data ?? ""), message.encoding === "base64" ? "base64" : "utf8");
|
|
if (message.stream === "stderr") {
|
|
stderr += chunk.toString("utf8");
|
|
queueStreamWrite(chunk, "stderr");
|
|
} else {
|
|
queueStreamWrite(chunk, "stdout");
|
|
}
|
|
return;
|
|
}
|
|
if (message.type === "ssh.error") {
|
|
stderr += `${String(message.message || "ssh bridge error")}\n`;
|
|
exitCode = 255;
|
|
ws.close();
|
|
return;
|
|
}
|
|
if (message.type === "ssh.exit") {
|
|
exitCode = Number.isInteger(message.exitCode) ? Number(message.exitCode) : 255;
|
|
ws.close();
|
|
}
|
|
});
|
|
ws.addEventListener("close", () => finish(exitCode));
|
|
ws.addEventListener("error", () => {
|
|
stderr += "unidesk remote frontend ssh bridge websocket error\n";
|
|
finish(255);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function runRemoteSshOverFrontend(session: FrontendSession, target: string | undefined, args: string[]): Promise<number> {
|
|
if (!target) throw new Error("remote ssh requires a route, for example: bun scripts/ssh-cli.ts --main-server-ip 74.48.78.17 ssh D601 hostname");
|
|
const normalizedArgs = normalizeSshOperationArgs(args);
|
|
process.stderr.write(sshRouteSeparatorCompatibilityHint(args, normalizedArgs));
|
|
const invocation = parseSshInvocation(target, normalizedArgs);
|
|
if (isSshFileTransferOperation(normalizedArgs)) {
|
|
const executor: SshRemoteCommandExecutor = {
|
|
runRemoteCommand: (remoteCommand, input) => runRemoteSshWebSocketCaptureRemoteCommand(session, invocation, remoteCommand, input),
|
|
streamRemoteCommand: (remoteCommand, handlers, input, options) => runRemoteSshWebSocketStreamRemoteCommand(session, invocation, remoteCommand, handlers, input, options),
|
|
};
|
|
return await runSshFileTransferOperation(invocation, normalizedArgs, executor, {
|
|
buildRouteCommand: remoteCommandForRoute,
|
|
buildWindowsPowerShellCommand: buildWindowsPowerShellInvocation,
|
|
});
|
|
}
|
|
if ((normalizedArgs[0] ?? "") === "apply-patch") {
|
|
const executor: ApplyPatchV2Executor = invocation.route.plane === "win"
|
|
? { fs: createWindowsApplyPatchFileSystem(invocation, (remoteCommand, input) => runRemoteSshWebSocketCaptureRemoteCommand(session, invocation, remoteCommand, input)) }
|
|
: invocation.route.plane === "host"
|
|
? { fs: createPosixApplyPatchFileSystem(invocation, (command, input) => runRemoteSshWebSocketCapture(session, invocation, command, input)) }
|
|
: { run: (command, input) => runRemoteSshWebSocketCapture(session, invocation, command, input) };
|
|
return await runApplyPatchV2({
|
|
executor,
|
|
stdin: process.stdin,
|
|
stdout: process.stdout,
|
|
stderr: process.stderr,
|
|
argv: normalizedArgs.slice(1),
|
|
timing: {
|
|
providerId: invocation.providerId,
|
|
route: invocation.route.raw,
|
|
transport: "frontend-websocket",
|
|
},
|
|
});
|
|
}
|
|
return runRemoteSshWebSocket(session, invocation);
|
|
}
|
|
|
|
async function runRemoteSshCliOverSsh(options: RemoteCliOptions): Promise<number> {
|
|
if (options.host === null) throw new Error("runRemoteSshCli requires --main-server-ip or --server");
|
|
const remoteArgs = normalizeSshCommandArgs(options.args);
|
|
const remoteCommand = [
|
|
"cd",
|
|
shellQuote(options.projectRoot),
|
|
"&&",
|
|
"exec",
|
|
"bun",
|
|
"scripts/ssh-cli.ts",
|
|
...remoteArgs.map(shellQuote),
|
|
].join(" ");
|
|
const sshArgs = [
|
|
"-p",
|
|
String(options.port),
|
|
"-o",
|
|
"BatchMode=yes",
|
|
"-o",
|
|
"StrictHostKeyChecking=accept-new",
|
|
"-o",
|
|
"ServerAliveInterval=20",
|
|
"-o",
|
|
"ServerAliveCountMax=3",
|
|
];
|
|
if (options.identityFile !== null) sshArgs.push("-i", options.identityFile);
|
|
sshArgs.push(`${options.user}@${options.host}`, remoteCommand);
|
|
|
|
const child = spawn("ssh", sshArgs, {
|
|
stdio: ["inherit", "inherit", "inherit"],
|
|
});
|
|
|
|
return await new Promise<number>((resolve) => {
|
|
child.on("error", (error) => {
|
|
process.stderr.write(`unidesk remote ssh cli failed to start ssh: ${error.message}\n`);
|
|
resolve(127);
|
|
});
|
|
child.on("close", (code) => resolve(code ?? 255));
|
|
});
|
|
}
|
|
|
|
async function runRemoteSshCliOverFrontend(options: RemoteCliOptions, config: UniDeskConfig): Promise<number> {
|
|
if (options.host === null) throw new Error("runRemoteSshCli requires --main-server-ip or --server");
|
|
const args = normalizeSshCommandArgs(options.args);
|
|
const [top, sub] = args;
|
|
if (top !== "ssh") throw new Error(`remote ssh cli supports only ssh commands, got: ${top || "<empty>"}`);
|
|
const scopedSshToken = sshClientTokenFromEnv();
|
|
const session = scopedSshToken === null ? await loginFrontend(options.host, config) : scopedSshFrontendSession(options.host, config, scopedSshToken);
|
|
return await runRemoteSshOverFrontend(session, sub, args.slice(2));
|
|
}
|
|
|
|
export async function runRemoteSshCli(options: RemoteCliOptions, config: UniDeskConfig): Promise<number> {
|
|
if (options.host === null) throw new Error("runRemoteSshCli requires --main-server-ip or --server");
|
|
const args = normalizeSshCommandArgs(options.args);
|
|
const useSsh = options.transport === "ssh" || (options.transport === "auto" && options.identityFile !== null);
|
|
if (useSsh) return runRemoteSshCliOverSsh({ ...options, args });
|
|
return runRemoteSshCliOverFrontend({ ...options, args }, config);
|
|
}
|
|
|
|
export async function runRemoteSshCommandCapture(
|
|
config: UniDeskConfig,
|
|
host: string,
|
|
target: string,
|
|
args: string[],
|
|
input?: string,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): Promise<SshCaptureResult> {
|
|
const token = sshClientTokenFromEnv(env);
|
|
const session = token === null
|
|
? await loginFrontend(host, config)
|
|
: scopedSshFrontendSession(host, config, token);
|
|
const normalizedArgs = normalizeSshOperationArgs(args);
|
|
const invocation = parseSshInvocation(target, normalizedArgs);
|
|
const parsed = invocation.parsed;
|
|
if (parsed.remoteCommand === null) throw new Error(`remote ssh ${target} capture requires a non-interactive operation`);
|
|
const stdin = parsed.stdinPrefix !== undefined || parsed.stdinSuffix !== undefined
|
|
? `${parsed.stdinPrefix ?? ""}${input ?? ""}${parsed.stdinSuffix ?? ""}`
|
|
: input;
|
|
return await runRemoteSshWebSocketCaptureRemoteCommand(session, invocation, parsed.remoteCommand, stdin);
|
|
}
|