feat: add provider ssh bridge

This commit is contained in:
Codex
2026-05-05 01:24:32 +00:00
parent 2d6829ed44
commit f6d0bd1e3b
18 changed files with 1014 additions and 34 deletions
+234 -6
View File
@@ -8,9 +8,19 @@ import {
type ApiNodeDockerStatus,
type ApiNodeSystemStatus,
type ApiTask,
type CoreHostSshCloseMessage,
type CoreHostSshEofMessage,
type CoreHostSshInputMessage,
type CoreHostSshOpenMessage,
type CoreHostSshResizeMessage,
type CoreDispatchMessage,
type JsonValue,
type ProviderHostSshDataMessage,
type ProviderHostSshErrorMessage,
type ProviderHostSshExitMessage,
type ProviderHostSshOpenedMessage,
type ProviderLabels,
isProviderDispatchCommand,
type ProviderToCoreMessage,
isProviderToCoreMessage,
} from "../../shared/src/index";
@@ -27,6 +37,8 @@ interface RuntimeConfig {
interface WsData {
providerId?: string;
channel?: "provider" | "ssh";
sessionId?: string;
}
type ProviderSocket = ServerWebSocket<WsData>;
@@ -35,6 +47,7 @@ type SqlClient = ReturnType<typeof postgres>;
const recentLogs: unknown[] = [];
const activeProviders = new Map<string, ProviderSocket>();
const activeSshClients = new Map<string, ProviderSocket>();
const serviceStartedAt = new Date();
const config = readConfig();
const logger = createLogger("backend-core", config.logFile);
@@ -281,11 +294,30 @@ async function upsertNodeOnline(providerId: string, name: string, labels: Provid
async function touchHeartbeat(providerId: string, labels: ProviderLabels): Promise<void> {
await sql`
UPDATE unidesk_nodes
SET labels = ${sql.json(labels)}, status = 'online', last_heartbeat = now(), updated_at = now()
SET labels = unidesk_nodes.labels || ${sql.json(labels)}, status = 'online', last_heartbeat = now(), updated_at = now()
WHERE provider_id = ${providerId}
`;
}
async function providerCapabilities(providerId: string): Promise<string[]> {
if (!dbReady) return [];
const rows = await sql<Array<{ labels: unknown }>>`
SELECT labels
FROM unidesk_nodes
WHERE provider_id = ${providerId}
LIMIT 1
`;
const labels = rows[0]?.labels;
if (typeof labels !== "object" || labels === null || Array.isArray(labels)) return [];
const capabilities = (labels as Record<string, unknown>).unideskCapabilities;
return Array.isArray(capabilities) ? capabilities.filter((item): item is string => typeof item === "string") : [];
}
async function providerSupports(providerId: string, capability: string): Promise<boolean> {
const capabilities = await providerCapabilities(providerId);
return capabilities.includes(capability);
}
async function upsertDockerStatus(providerId: string, status: JsonValue, collectedAt: string): Promise<void> {
await sql`
INSERT INTO unidesk_node_docker_status (provider_id, status, collected_at, updated_at)
@@ -415,17 +447,75 @@ function parseMessage(raw: string | Buffer): ProviderToCoreMessage {
return parsed;
}
function safeSessionId(): string {
return `ssh_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
function wsSendJson(ws: ProviderSocket, value: unknown): void {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(value));
}
function sshClientFor(sessionId: string): ProviderSocket | null {
return activeSshClients.get(sessionId) ?? null;
}
function providerForSsh(providerId: string): ProviderSocket | null {
return activeProviders.get(providerId) ?? null;
}
function closeSshClient(sessionId: string, code = 1000, reason = "ssh session closed"): void {
const client = sshClientFor(sessionId);
activeSshClients.delete(sessionId);
if (client !== null && client.readyState === WebSocket.OPEN) client.close(code, reason);
}
function forwardSshProviderMessage(
message: ProviderHostSshOpenedMessage | ProviderHostSshDataMessage | ProviderHostSshExitMessage | ProviderHostSshErrorMessage,
): void {
const client = sshClientFor(message.sessionId);
if (client === null) {
logger("warn", "ssh_client_missing", { providerId: message.providerId, sessionId: message.sessionId, type: message.type });
return;
}
if (message.type === "host_ssh_opened") {
wsSendJson(client, { type: "ssh.opened", providerId: message.providerId, sessionId: message.sessionId });
return;
}
if (message.type === "host_ssh_data") {
wsSendJson(client, { type: "ssh.data", stream: message.stream, data: message.data, encoding: message.encoding });
return;
}
if (message.type === "host_ssh_exit") {
wsSendJson(client, { type: "ssh.exit", exitCode: message.exitCode, signal: message.signal });
setTimeout(() => closeSshClient(message.sessionId), 50);
return;
}
wsSendJson(client, { type: "ssh.error", message: message.message });
setTimeout(() => closeSshClient(message.sessionId, 1011, "ssh session error"), 50);
}
async function handleProviderMessage(ws: ProviderSocket, raw: string | Buffer): Promise<void> {
const message = parseMessage(raw);
ws.data.providerId = message.providerId;
activeProviders.set(message.providerId, ws);
if (
message.type === "host_ssh_opened" ||
message.type === "host_ssh_data" ||
message.type === "host_ssh_exit" ||
message.type === "host_ssh_error"
) {
forwardSshProviderMessage(message);
return;
}
if (message.type === "register") {
await upsertNodeOnline(message.providerId, message.name, message.labels);
const labels = { ...message.labels, unideskCapabilities: message.capabilities };
await upsertNodeOnline(message.providerId, message.name, labels);
await recordEvent("provider_registered", message.providerId, {
providerId: message.providerId,
name: message.name,
labels: message.labels,
labels,
capabilities: message.capabilities,
});
ws.send(JSON.stringify({ type: "ack", requestId: "register", ok: true, message: "registered" }));
@@ -648,11 +738,17 @@ async function getOverview(): Promise<JsonValue> {
async function dispatchTask(req: Request): Promise<Response> {
const body = (await req.json()) as { providerId?: unknown; command?: unknown; payload?: unknown };
const providerId = typeof body.providerId === "string" ? body.providerId : "";
const command = body.command === "docker.ps" || body.command === "provider.upgrade" || body.command === "echo" ? body.command : "echo";
const command = isProviderDispatchCommand(body.command) ? body.command : null;
const payload = typeof body.payload === "object" && body.payload !== null ? (body.payload as Record<string, JsonValue>) : {};
if (!providerId) {
return jsonResponse({ ok: false, error: "providerId is required" }, 400);
}
if (command === null) {
return jsonResponse({ ok: false, error: "command must be one of docker.ps, provider.upgrade, host.ssh, echo" }, 400);
}
if (command === "host.ssh" && !(await providerSupports(providerId, "host.ssh"))) {
return jsonResponse({ ok: false, error: `provider does not declare host.ssh capability: ${providerId}` }, 409);
}
const taskId = `task_${Date.now()}_${Math.random().toString(16).slice(2)}`;
await sql`
INSERT INTO unidesk_tasks (id, provider_id, command, status, payload, result)
@@ -674,11 +770,113 @@ async function dispatchTask(req: Request): Promise<Response> {
return jsonResponse({ ok: true, taskId, status: "dispatched", providerOnline: true });
}
async function route(req: Request): Promise<Response> {
function numberFromUnknown(value: unknown, fallback: number, min: number, max: number): number {
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : fallback;
if (!Number.isFinite(parsed)) return fallback;
return Math.max(min, Math.min(max, Math.floor(parsed)));
}
async function handleSshClientMessage(ws: ProviderSocket, raw: string | Buffer): Promise<void> {
const text = typeof raw === "string" ? raw : raw.toString("utf8");
const message = JSON.parse(text) as { type?: unknown; providerId?: unknown; cwd?: unknown; command?: unknown; data?: unknown; encoding?: unknown; cols?: unknown; rows?: unknown };
if (message.type === "ssh.open") {
const providerId = typeof message.providerId === "string" ? message.providerId : "";
if (providerId.length === 0) {
wsSendJson(ws, { type: "ssh.error", message: "providerId is required" });
return;
}
const provider = providerForSsh(providerId);
if (provider === null) {
wsSendJson(ws, { type: "ssh.error", message: `provider is not online: ${providerId}` });
return;
}
if (!(await providerSupports(providerId, "host.ssh"))) {
wsSendJson(ws, { type: "ssh.error", message: `provider does not declare host.ssh capability: ${providerId}` });
return;
}
const sessionId = safeSessionId();
ws.data.channel = "ssh";
ws.data.providerId = providerId;
ws.data.sessionId = sessionId;
activeSshClients.set(sessionId, ws);
const openMessage: CoreHostSshOpenMessage = {
type: "host_ssh_open",
sessionId,
cols: numberFromUnknown(message.cols, 100, 20, 300),
rows: numberFromUnknown(message.rows, 30, 8, 120),
};
if (typeof message.cwd === "string" && message.cwd.length > 0) openMessage.cwd = message.cwd;
if (typeof message.command === "string" && message.command.length > 0) openMessage.command = message.command;
provider.send(JSON.stringify(openMessage));
wsSendJson(ws, { type: "ssh.dispatched", providerId, sessionId });
logger("info", "ssh_session_dispatched", { providerId, sessionId, hasCommand: typeof openMessage.command === "string" });
return;
}
const sessionId = ws.data.sessionId;
const providerId = ws.data.providerId;
if (sessionId === undefined || providerId === undefined) {
if (message.type === "ssh.eof" || message.type === "ssh.close") return;
wsSendJson(ws, { type: "ssh.error", message: "ssh session is not open" });
return;
}
const provider = providerForSsh(providerId);
if (provider === null) {
wsSendJson(ws, { type: "ssh.error", message: `provider went offline: ${providerId}` });
return;
}
if (message.type === "ssh.input") {
if (typeof message.data !== "string" || message.encoding !== "base64") {
wsSendJson(ws, { type: "ssh.error", message: "ssh.input requires base64 data" });
return;
}
const inputMessage: CoreHostSshInputMessage = { type: "host_ssh_input", sessionId, data: message.data, encoding: "base64" };
provider.send(JSON.stringify(inputMessage));
return;
}
if (message.type === "ssh.resize") {
const resizeMessage: CoreHostSshResizeMessage = {
type: "host_ssh_resize",
sessionId,
cols: numberFromUnknown(message.cols, 100, 20, 300),
rows: numberFromUnknown(message.rows, 30, 8, 120),
};
provider.send(JSON.stringify(resizeMessage));
return;
}
if (message.type === "ssh.eof") {
const eofMessage: CoreHostSshEofMessage = { type: "host_ssh_eof", sessionId };
provider.send(JSON.stringify(eofMessage));
return;
}
if (message.type === "ssh.close") {
const closeMessage: CoreHostSshCloseMessage = { type: "host_ssh_close", sessionId };
provider.send(JSON.stringify(closeMessage));
closeSshClient(sessionId);
return;
}
wsSendJson(ws, { type: "ssh.error", message: `unsupported ssh client message: ${String(message.type)}` });
}
async function sshRoute(req: Request, server: Server<WsData>): Promise<Response | undefined> {
const url = new URL(req.url);
const token = url.searchParams.get("token") ?? req.headers.get("x-provider-token");
if (token !== config.providerToken) return jsonResponse({ ok: false, error: "invalid ssh bridge token" }, 401);
const upgraded = server.upgrade(req, { data: { channel: "ssh" } satisfies WsData });
return upgraded ? undefined : jsonResponse({ ok: false, error: "websocket upgrade failed" }, 400);
}
async function route(req: Request, server: Server<WsData>): Promise<Response | undefined> {
const url = new URL(req.url);
if (req.method === "OPTIONS") return jsonResponse({ ok: true });
try {
if (url.pathname === "/ws/ssh") return sshRoute(req, server);
if (url.pathname === "/" || url.pathname === "/health") {
return jsonResponse({ ok: true, service: "unidesk-core", dbReady, startedAt: serviceStartedAt.toISOString() });
}
@@ -714,7 +912,7 @@ async function providerRoute(req: Request, server: Server<WsData>): Promise<Resp
await recordEvent("provider_auth_failed", "unknown", { remote: req.headers.get("x-forwarded-for") ?? null });
return jsonResponse({ ok: false, error: "invalid provider token" }, 401);
}
const upgraded = server.upgrade(req, { data: {} satisfies WsData });
const upgraded = server.upgrade(req, { data: { channel: "provider" } satisfies WsData });
return upgraded ? undefined : jsonResponse({ ok: false, error: "websocket upgrade failed" }, 400);
}
@@ -733,6 +931,36 @@ const apiServer = Bun.serve<WsData>({
port: config.port,
hostname: "0.0.0.0",
fetch: route,
websocket: {
open(ws) {
if (ws.data.channel === "ssh") logger("info", "ssh_client_open");
},
message(ws, raw) {
if (ws.data.channel !== "ssh") {
ws.close(1008, "unsupported websocket channel");
return;
}
handleSshClientMessage(ws, raw).catch((error) => {
logger("error", "ssh_client_message_failed", { providerId: ws.data.providerId ?? null, sessionId: ws.data.sessionId ?? null, error: errorToJson(error) });
wsSendJson(ws, { type: "ssh.error", message: error instanceof Error ? error.message : String(error) });
});
},
close(ws) {
if (ws.data.channel !== "ssh") return;
const { sessionId, providerId } = ws.data;
logger("warn", "ssh_client_close", { providerId: providerId ?? null, sessionId: sessionId ?? null });
if (sessionId !== undefined) {
activeSshClients.delete(sessionId);
if (providerId !== undefined) {
const provider = providerForSsh(providerId);
if (provider !== null) {
const closeMessage: CoreHostSshCloseMessage = { type: "host_ssh_close", sessionId };
provider.send(JSON.stringify(closeMessage));
}
}
}
},
},
});
const providerServer = Bun.serve<WsData>({
+1
View File
@@ -835,6 +835,7 @@ function DispatchPage({ nodes, onDispatched, onRaw }: AnyRecord) {
)),
h("label", null, "Command", h("select", { value: command, onChange: (event: any) => setCommand(event.target.value) },
h("option", { value: "docker.ps" }, "docker.ps"),
h("option", { value: "host.ssh" }, "host.ssh"),
h("option", { value: "echo" }, "echo"),
)),
h("label", null, "来源", h("input", { value: source, onChange: (event: any) => setSource(event.target.value) })),
+361 -4
View File
@@ -2,6 +2,11 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { dirname } from "node:path";
import {
type CoreDispatchMessage,
type CoreHostSshCloseMessage,
type CoreHostSshEofMessage,
type CoreHostSshInputMessage,
type CoreHostSshOpenMessage,
type CoreHostSshResizeMessage,
type DockerContainerSummary,
type DockerImageSummary,
type DockerNetworkSummary,
@@ -33,6 +38,12 @@ interface RuntimeConfig {
upgradeComposeProject: string;
upgradeService: string;
upgradeRunnerImage: string;
hostSshHost: string | null;
hostSshPort: number | null;
hostSshUser: string | null;
hostSshKey: string | null;
hostRemoteCwd: string | null;
hostLoginShell: string | null;
logFile: string;
}
@@ -49,6 +60,18 @@ let previousCpuSample: { idle: number; total: number } | null = null;
let reconnectAttempt = 0;
let stopping = false;
interface HostSshSession {
proc: ReturnType<typeof Bun.spawn>;
openedAt: number;
}
interface HostSshStdin {
write(chunk: Uint8Array): unknown;
end(): unknown;
}
const hostSshSessions = new Map<string, HostSshSession>();
function requiredEnv(name: string): string {
const value = process.env[name];
if (value === undefined || value.length === 0) {
@@ -66,6 +89,22 @@ function readNumberEnv(name: string): number {
return parsed;
}
function readOptionalStringEnv(name: string): string | null {
const value = process.env[name];
if (value === undefined || value.length === 0) return null;
return value;
}
function readOptionalNumberEnv(name: string): number | null {
const raw = readOptionalStringEnv(name);
if (raw === null) return null;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Environment variable ${name} must be a positive number, got ${raw}`);
}
return parsed;
}
function readBooleanEnv(name: string): boolean {
const raw = requiredEnv(name);
if (raw === "true") return true;
@@ -93,6 +132,12 @@ function readConfig(): RuntimeConfig {
upgradeComposeProject: requiredEnv("PROVIDER_UPGRADE_COMPOSE_PROJECT"),
upgradeService: requiredEnv("PROVIDER_UPGRADE_SERVICE"),
upgradeRunnerImage: requiredEnv("PROVIDER_UPGRADE_RUNNER_IMAGE"),
hostSshHost: readOptionalStringEnv("HOST_SSH_HOST"),
hostSshPort: readOptionalNumberEnv("HOST_SSH_PORT"),
hostSshUser: readOptionalStringEnv("HOST_SSH_USER"),
hostSshKey: readOptionalStringEnv("HOST_SSH_KEY"),
hostRemoteCwd: readOptionalStringEnv("HOST_REMOTE_CWD"),
hostLoginShell: readOptionalStringEnv("HOST_LOGIN_SHELL"),
logFile: requiredEnv("LOG_FILE"),
};
}
@@ -121,9 +166,13 @@ function withToken(rawUrl: string, token: string): string {
}
function currentLabels(): ProviderLabels {
const hostSshConfigured = isHostSshConfigured();
return {
...config.labels,
dockerSocketPresent: existsSync(config.dockerSocketPath),
hostSshConfigured,
hostSshKeyPresent: config.hostSshKey !== null && existsSync(config.hostSshKey),
hostSshTarget: hostSshConfigured ? `${config.hostSshUser}@${config.hostSshHost}:${config.hostSshPort}` : "not-configured",
runtime: "bun",
gatewayUptimeSeconds: Math.floor((Date.now() - startedAt.getTime()) / 1000),
};
@@ -135,13 +184,15 @@ function sendJson(value: unknown): void {
}
function sendRegister(): void {
const capabilities = ["heartbeat", "system.status", "docker.status", "docker.ps", "provider.upgrade", "echo"];
if (isHostSshConfigured()) capabilities.push("host.ssh");
sendJson({
type: "register",
providerId: config.providerId,
name: config.providerName,
labels: currentLabels(),
startedAt: startedAt.toISOString(),
capabilities: ["heartbeat", "system.status", "docker.status", "docker.ps", "provider.upgrade", "echo"],
capabilities,
});
}
@@ -209,19 +260,23 @@ async function sendTaskStatus(taskId: string, status: ProviderTaskStatusMessage[
});
}
async function runProcessCommand(command: string, args: string[], timeoutMs = 6000): Promise<{ ok: boolean; stdout: string; stderr: string; exitCode: number }> {
async function runProcessCommand(command: string, args: string[], timeoutMs = 6000): Promise<{ ok: boolean; stdout: string; stderr: string; exitCode: number; timedOut: boolean }> {
const proc = Bun.spawn([command, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const timeout = setTimeout(() => proc.kill("SIGKILL"), timeoutMs);
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
proc.kill("SIGKILL");
}, timeoutMs);
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
clearTimeout(timeout);
return { ok: exitCode === 0, stdout, stderr, exitCode };
return { ok: exitCode === 0 && !timedOut, stdout, stderr, exitCode, timedOut };
}
async function runDockerCommand(args: string[], timeoutMs = 6000): Promise<{ ok: boolean; stdout: string; stderr: string; exitCode: number }> {
@@ -508,6 +563,274 @@ function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function isHostSshConfigured(): boolean {
return config.hostSshHost !== null && config.hostSshPort !== null && config.hostSshUser !== null && config.hostSshKey !== null;
}
function missingHostSshFields(): string[] {
const missing: string[] = [];
if (config.hostSshHost === null) missing.push("HOST_SSH_HOST");
if (config.hostSshPort === null) missing.push("HOST_SSH_PORT");
if (config.hostSshUser === null) missing.push("HOST_SSH_USER");
if (config.hostSshKey === null) missing.push("HOST_SSH_KEY");
return missing;
}
function payloadString(payload: Record<string, JsonValue>, key: string): string | null {
const value = payload[key];
return typeof value === "string" && value.length > 0 ? value : null;
}
function payloadTimeoutMs(payload: Record<string, JsonValue>): number {
const raw = payload.timeoutMs;
const value = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : 8000;
if (!Number.isFinite(value) || value <= 0) return 8000;
return Math.max(1000, Math.min(15_000, Math.floor(value)));
}
function truncateText(value: string, maxLength = 4000): string {
return value.length > maxLength ? `${value.slice(0, maxLength)}...<truncated:${value.length}>` : value;
}
function safeKnownHostsName(providerId: string): string {
return providerId.replace(/[^a-zA-Z0-9_.-]/g, "-").slice(0, 80) || "provider";
}
function defaultHostSshProbeCommand(): string {
return "printf 'UNIDESK_SSH_TEST user=%s host=%s bridge=%s cwd=%s\\n' \"$(whoami)\" \"$(hostname)\" \"${UNIDESK_BRIDGE:-}\" \"$(pwd)\"";
}
async function runHostSsh(payload: Record<string, JsonValue>): Promise<JsonValue> {
if (!isHostSshConfigured()) {
throw new Error(`host SSH bridge is not configured; missing ${missingHostSshFields().join(", ")}`);
}
const host = config.hostSshHost ?? "";
const port = config.hostSshPort ?? 22;
const user = config.hostSshUser ?? "";
const key = config.hostSshKey ?? "";
if (!existsSync(key)) {
throw new Error(`host SSH key is not mounted at ${key}`);
}
const mode = payload.mode === "exec" ? "exec" : "probe";
const timeoutMs = payloadTimeoutMs(payload);
const requestedCommand = payloadString(payload, "command");
const command = mode === "exec" && requestedCommand !== null ? requestedCommand : defaultHostSshProbeCommand();
if (command.length > 4000) {
throw new Error(`host SSH command is too long: ${command.length} bytes`);
}
const cwd = payloadString(payload, "cwd") ?? config.hostRemoteCwd;
const scriptParts = [
"set -e",
cwd === null ? null : `cd ${shellQuote(cwd)}`,
"export UNIDESK_BRIDGE=host.ssh",
`export UNIDESK_PROVIDER_ID=${shellQuote(config.providerId)}`,
config.hostLoginShell === null
? command
: `${shellQuote(config.hostLoginShell)} -lc ${shellQuote(command)}`,
].filter((part): part is string => part !== null);
const remoteScript = scriptParts.join("; ");
const result = await runProcessCommand("ssh", [
"-T",
"-i",
key,
"-p",
String(port),
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
"-o",
`UserKnownHostsFile=/tmp/unidesk-host-known-hosts-${safeKnownHostsName(config.providerId)}`,
"-o",
"ServerAliveInterval=20",
"-o",
"ServerAliveCountMax=3",
`${user}@${host}`,
remoteScript,
], timeoutMs);
const stdout = truncateText(result.stdout);
const stderr = truncateText(result.stderr);
const probeLine = stdout.split("\n").find((line) => line.includes("UNIDESK_SSH_TEST")) ?? "";
return {
ok: result.ok,
mode,
host,
port,
user,
cwd: cwd ?? "",
timeoutMs,
timedOut: result.timedOut,
exitCode: result.exitCode,
command: truncateText(command, 1000),
stdout,
stderr,
probeLine,
sshKeyPresent: true,
};
}
function hostSshRemoteScript(command: string | null, cwd: string | null, cols?: number, rows?: number): string {
const fallbackCwd = config.hostRemoteCwd ?? `/home/${config.hostSshUser ?? "root"}`;
const requestedCwd = cwd ?? fallbackCwd;
const loginShell = config.hostLoginShell ?? "/bin/bash";
const resize = Number.isFinite(cols) && Number.isFinite(rows)
? `stty rows ${Math.max(8, Math.min(120, Math.floor(rows ?? 30)))} cols ${Math.max(20, Math.min(300, Math.floor(cols ?? 100)))} 2>/dev/null || true`
: "true";
const enterCwd = `cd ${shellQuote(requestedCwd)} 2>/dev/null || cd ${shellQuote(fallbackCwd)} 2>/dev/null || cd`;
const exports = `export UNIDESK_BRIDGE=host.ssh; export UNIDESK_PROVIDER_ID=${shellQuote(config.providerId)}`;
const execPart = command === null || command.length === 0
? `exec ${shellQuote(loginShell)} -l`
: `${shellQuote(loginShell)} -lc ${shellQuote(command)}`;
return `${enterCwd}; ${exports}; ${resize}; ${execPart}`;
}
function hostSshArgs(remoteScript: string): string[] {
if (!isHostSshConfigured()) {
throw new Error(`host SSH bridge is not configured; missing ${missingHostSshFields().join(", ")}`);
}
const key = config.hostSshKey ?? "";
if (!existsSync(key)) {
throw new Error(`host SSH key is not mounted at ${key}`);
}
return [
"-tt",
"-i",
key,
"-p",
String(config.hostSshPort ?? 22),
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
"-o",
`UserKnownHostsFile=/tmp/unidesk-host-known-hosts-${safeKnownHostsName(config.providerId)}`,
"-o",
"ServerAliveInterval=20",
"-o",
"ServerAliveCountMax=3",
`${config.hostSshUser}@${config.hostSshHost}`,
remoteScript,
];
}
async function pumpHostSshOutput(sessionId: string, streamName: "stdout" | "stderr", stream: ReadableStream<Uint8Array> | null): Promise<void> {
if (stream === null) return;
const reader = stream.getReader();
while (true) {
const chunk = await reader.read();
if (chunk.done) return;
sendJson({
type: "host_ssh_data",
providerId: config.providerId,
sessionId,
stream: streamName,
data: Buffer.from(chunk.value).toString("base64"),
encoding: "base64",
at: new Date().toISOString(),
});
}
}
function sendHostSshError(sessionId: string, error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
logger("error", "host_ssh_session_error", { sessionId, error: message });
sendJson({
type: "host_ssh_error",
providerId: config.providerId,
sessionId,
message,
at: new Date().toISOString(),
});
}
function startHostSshSession(message: CoreHostSshOpenMessage): void {
if (hostSshSessions.has(message.sessionId)) {
sendHostSshError(message.sessionId, `host SSH session already exists: ${message.sessionId}`);
return;
}
try {
const remoteScript = hostSshRemoteScript(message.command ?? null, message.cwd ?? null, message.cols, message.rows);
const proc = Bun.spawn(["ssh", ...hostSshArgs(remoteScript)], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
hostSshSessions.set(message.sessionId, { proc, openedAt: Date.now() });
sendJson({
type: "host_ssh_opened",
providerId: config.providerId,
sessionId: message.sessionId,
at: new Date().toISOString(),
});
const stdoutDone = pumpHostSshOutput(message.sessionId, "stdout", proc.stdout);
const stderrDone = pumpHostSshOutput(message.sessionId, "stderr", proc.stderr);
Promise.all([stdoutDone, stderrDone, proc.exited])
.then(([, , exitCode]) => {
hostSshSessions.delete(message.sessionId);
sendJson({
type: "host_ssh_exit",
providerId: config.providerId,
sessionId: message.sessionId,
exitCode,
signal: null,
at: new Date().toISOString(),
});
})
.catch((error) => {
hostSshSessions.delete(message.sessionId);
sendHostSshError(message.sessionId, error);
});
logger("info", "host_ssh_session_started", { sessionId: message.sessionId, hasCommand: typeof message.command === "string", cwd: message.cwd ?? null });
} catch (error) {
sendHostSshError(message.sessionId, error);
}
}
function writeHostSshInput(message: CoreHostSshInputMessage): void {
const session = hostSshSessions.get(message.sessionId);
if (session === undefined) {
sendHostSshError(message.sessionId, `unknown host SSH session: ${message.sessionId}`);
return;
}
try {
const stdin = session.proc.stdin as HostSshStdin | undefined;
if (stdin === undefined) throw new Error("host SSH stdin is not available");
stdin.write(Buffer.from(message.data, "base64"));
} catch (error) {
sendHostSshError(message.sessionId, error);
}
}
function resizeHostSshSession(message: CoreHostSshResizeMessage): void {
logger("debug", "host_ssh_resize_requested", { sessionId: message.sessionId, cols: message.cols, rows: message.rows });
}
function eofHostSshSession(message: CoreHostSshEofMessage): void {
const session = hostSshSessions.get(message.sessionId);
if (session === undefined) return;
try {
const stdin = session.proc.stdin as HostSshStdin | undefined;
if (stdin !== undefined) stdin.end();
} catch (error) {
sendHostSshError(message.sessionId, error);
}
}
function closeHostSshSession(message: CoreHostSshCloseMessage): void {
const session = hostSshSessions.get(message.sessionId);
if (session === undefined) return;
hostSshSessions.delete(message.sessionId);
session.proc.kill("SIGTERM");
setTimeout(() => {
try {
session.proc.kill("SIGKILL");
} catch {
/* process may already be gone */
}
}, 1000);
}
function safeDockerName(value: string): string {
return value.replace(/[^a-zA-Z0-9_.-]/g, "-").slice(0, 80);
}
@@ -603,6 +926,15 @@ async function handleDispatch(message: CoreDispatchMessage): Promise<void> {
await sendTaskStatus(message.taskId, "succeeded", "provider upgrade command completed", result);
return;
}
if (message.command === "host.ssh") {
const result = await runHostSsh(message.payload);
if ((result as { ok?: unknown }).ok !== true) {
await sendTaskStatus(message.taskId, "failed", "host SSH command failed", result);
return;
}
await sendTaskStatus(message.taskId, "succeeded", "host SSH command completed", result);
return;
}
await sendTaskStatus(message.taskId, "succeeded", "echo completed", { echo: message.payload });
} catch (error) {
const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
@@ -618,6 +950,26 @@ function handleMessage(raw: MessageEvent<string>): void {
handleDispatch(parsed as CoreDispatchMessage).catch((error) => logger("error", "dispatch_handler_failed", { error: String(error) }));
return;
}
if (parsed.type === "host_ssh_open") {
startHostSshSession(parsed as CoreHostSshOpenMessage);
return;
}
if (parsed.type === "host_ssh_input") {
writeHostSshInput(parsed as CoreHostSshInputMessage);
return;
}
if (parsed.type === "host_ssh_resize") {
resizeHostSshSession(parsed as CoreHostSshResizeMessage);
return;
}
if (parsed.type === "host_ssh_eof") {
eofHostSshSession(parsed as CoreHostSshEofMessage);
return;
}
if (parsed.type === "host_ssh_close") {
closeHostSshSession(parsed as CoreHostSshCloseMessage);
return;
}
logger("debug", "core_message", parsed as JsonValue);
} catch (error) {
logger("error", "core_message_parse_failed", { error: String(error) });
@@ -676,6 +1028,11 @@ process.on("SIGTERM", () => {
if (heartbeatTimer !== null) clearInterval(heartbeatTimer);
if (systemStatusTimer !== null) clearInterval(systemStatusTimer);
if (dockerStatusTimer !== null) clearInterval(dockerStatusTimer);
for (const [sessionId, session] of hostSshSessions) {
logger("warn", "host_ssh_session_terminated_by_shutdown", { sessionId });
session.proc.kill("SIGTERM");
}
hostSshSessions.clear();
socket?.close(1000, "provider shutdown");
process.exit(0);
});
+98 -4
View File
@@ -106,13 +106,48 @@ export interface ProviderTaskStatusMessage {
result?: JsonValue;
}
export type ProviderDispatchCommand = "docker.ps" | "provider.upgrade" | "host.ssh" | "echo";
export interface CoreDispatchMessage {
type: "dispatch";
taskId: string;
command: "docker.ps" | "provider.upgrade" | "echo";
command: ProviderDispatchCommand;
payload: Record<string, JsonValue>;
}
export interface CoreHostSshOpenMessage {
type: "host_ssh_open";
sessionId: string;
cwd?: string;
command?: string;
cols?: number;
rows?: number;
}
export interface CoreHostSshInputMessage {
type: "host_ssh_input";
sessionId: string;
data: string;
encoding: "base64";
}
export interface CoreHostSshResizeMessage {
type: "host_ssh_resize";
sessionId: string;
cols: number;
rows: number;
}
export interface CoreHostSshCloseMessage {
type: "host_ssh_close";
sessionId: string;
}
export interface CoreHostSshEofMessage {
type: "host_ssh_eof";
sessionId: string;
}
export interface CoreAcknowledgeMessage {
type: "ack";
requestId: string;
@@ -120,14 +155,59 @@ export interface CoreAcknowledgeMessage {
message: string;
}
export interface ProviderHostSshOpenedMessage {
type: "host_ssh_opened";
providerId: string;
sessionId: string;
at: string;
}
export interface ProviderHostSshDataMessage {
type: "host_ssh_data";
providerId: string;
sessionId: string;
stream: "stdout" | "stderr";
data: string;
encoding: "base64";
at: string;
}
export interface ProviderHostSshExitMessage {
type: "host_ssh_exit";
providerId: string;
sessionId: string;
exitCode: number | null;
signal: string | null;
at: string;
}
export interface ProviderHostSshErrorMessage {
type: "host_ssh_error";
providerId: string;
sessionId: string;
message: string;
at: string;
}
export type ProviderToCoreMessage =
| ProviderRegisterMessage
| ProviderHeartbeatMessage
| ProviderSystemStatusMessage
| ProviderDockerStatusMessage
| ProviderTaskStatusMessage;
| ProviderTaskStatusMessage
| ProviderHostSshOpenedMessage
| ProviderHostSshDataMessage
| ProviderHostSshExitMessage
| ProviderHostSshErrorMessage;
export type CoreToProviderMessage = CoreDispatchMessage | CoreAcknowledgeMessage;
export type CoreToProviderMessage =
| CoreDispatchMessage
| CoreHostSshOpenMessage
| CoreHostSshInputMessage
| CoreHostSshResizeMessage
| CoreHostSshCloseMessage
| CoreHostSshEofMessage
| CoreAcknowledgeMessage;
export interface ApiNode {
providerId: string;
@@ -190,11 +270,25 @@ export function parseJsonObject(value: string, name: string): Record<string, Jso
return parsed as Record<string, JsonValue>;
}
export function isProviderDispatchCommand(value: unknown): value is ProviderDispatchCommand {
return value === "docker.ps" || value === "provider.upgrade" || value === "host.ssh" || value === "echo";
}
export function isProviderToCoreMessage(value: unknown): value is ProviderToCoreMessage {
if (typeof value !== "object" || value === null || !("type" in value)) return false;
const msg = value as { type?: unknown; providerId?: unknown };
return (
(msg.type === "register" || msg.type === "heartbeat" || msg.type === "system_status" || msg.type === "docker_status" || msg.type === "task_status") &&
(
msg.type === "register" ||
msg.type === "heartbeat" ||
msg.type === "system_status" ||
msg.type === "docker_status" ||
msg.type === "task_status" ||
msg.type === "host_ssh_opened" ||
msg.type === "host_ssh_data" ||
msg.type === "host_ssh_exit" ||
msg.type === "host_ssh_error"
) &&
typeof msg.providerId === "string" &&
msg.providerId.length > 0
);