feat: add provider ssh bridge
This commit is contained in:
@@ -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>({
|
||||
|
||||
@@ -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) })),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user