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
+54 -5
View File
@@ -1,10 +1,11 @@
import { readConfig } from "./src/config";
import { debugDispatch, debugHealth } from "./src/debug";
import { debugDispatch, debugHealth, debugTask, isDebugDispatchCommand, type DebugDispatchCommand } from "./src/debug";
import { stackLogs, stackStatus, startStack, stopStack } from "./src/docker";
import { runE2E } from "./src/e2e";
import { emitError, emitJson } from "./src/output";
import { jobWithTail, listJobs, readJob, runJob } from "./src/jobs";
import { runChecks } from "./src/check";
import { runSsh } from "./src/ssh";
const args = process.argv.slice(2);
const commandName = args.join(" ") || "help";
@@ -21,10 +22,12 @@ function help(): unknown {
{ command: "server stop", description: "Fire-and-forget docker-compose down for the fixed UniDesk stack." },
{ command: "server status", description: "Show fixed ports, containers, service health, and public URLs." },
{ command: "server logs [--tail-bytes N]", description: "Return bounded tails from file logs and docker logs." },
{ command: "ssh <providerId> [ssh-like args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge." },
{ command: "job list", description: "List async jobs from .state/jobs." },
{ command: "job status <jobId|latest> [--tail-bytes N]", description: "Show job state with bounded stdout/stderr tails." },
{ command: "debug health", description: "Probe internal core, nodes, system/Docker status, frontend, provider ingress, and public boundary." },
{ command: "debug dispatch [providerId] [docker.ps|provider.upgrade|echo]", description: "Submit a real internal-core dispatch request for CLI debugging." },
{ command: "debug dispatch [providerId] [docker.ps|provider.upgrade|host.ssh|echo] [--wait-ms N]", description: "Submit a real internal-core dispatch request for CLI debugging." },
{ command: "debug task <taskId|latest>", description: "Read a dispatched task record from internal core for CLI debugging." },
{ command: "e2e run", description: "Run public frontend/provider, internal core/database, and Playwright login E2E checks." },
],
};
@@ -39,6 +42,41 @@ function numberOption(name: string, defaultValue: number): number {
return value;
}
function stringOption(name: string): string | undefined {
const index = args.indexOf(name);
if (index === -1) return undefined;
const raw = args[index + 1];
if (raw === undefined || raw.length === 0) throw new Error(`${name} requires a non-empty value`);
return raw;
}
function jsonOption(name: string): Record<string, unknown> | undefined {
const raw = stringOption(name);
if (raw === undefined) return undefined;
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`${name} must be a JSON object`);
return parsed as Record<string, unknown>;
}
function dispatchPayload(command: DebugDispatchCommand): Record<string, unknown> {
const explicit = jsonOption("--payload-json") ?? {};
if (command === "provider.upgrade") {
return { source: "cli-debug", mode: stringOption("--mode") ?? stringOption("--upgrade-mode") ?? "plan", ...explicit };
}
if (command === "host.ssh") {
const sshCommand = stringOption("--ssh-command");
return {
source: "cli-debug",
mode: sshCommand === undefined ? "probe" : "exec",
...(sshCommand === undefined ? {} : { command: sshCommand }),
...(stringOption("--cwd") === undefined ? {} : { cwd: stringOption("--cwd") }),
...(args.includes("--timeout-ms") ? { timeoutMs: numberOption("--timeout-ms", 8000) } : {}),
...explicit,
};
}
return { source: "cli-debug", ...explicit };
}
function latestJobId(): string {
const jobs = listJobs();
if (jobs.length === 0) throw new Error("No jobs found");
@@ -60,6 +98,12 @@ async function main(): Promise<void> {
const config = readConfig();
if (top === "ssh") {
const exitCode = await runSsh(config, sub ?? "", args.slice(2));
process.exitCode = exitCode;
return;
}
if (top === "config" && sub === "show") {
emitJson(commandName, { config });
return;
@@ -109,9 +153,14 @@ async function main(): Promise<void> {
return;
}
if (sub === "dispatch") {
const providerId = third ?? config.providerGateway.id;
const dispatchCommand = fourth === "docker.ps" || fourth === "provider.upgrade" || fourth === "echo" ? fourth : "docker.ps";
emitJson(commandName, await debugDispatch(config, providerId, dispatchCommand));
const providerId = isDebugDispatchCommand(third) ? config.providerGateway.id : third ?? config.providerGateway.id;
const commandArg = isDebugDispatchCommand(third) ? third : fourth;
const dispatchCommand = isDebugDispatchCommand(commandArg) ? commandArg : "docker.ps";
emitJson(commandName, await debugDispatch(config, providerId, dispatchCommand, dispatchPayload(dispatchCommand), numberOption("--wait-ms", 0)));
return;
}
if (sub === "task") {
emitJson(commandName, await debugTask(config, third ?? "latest"));
return;
}
}
+42 -5
View File
@@ -1,6 +1,13 @@
import { runCommand } from "./command";
import { type UniDeskConfig, repoRoot } from "./config";
export const dispatchCommands = ["docker.ps", "provider.upgrade", "host.ssh", "echo"] as const;
export type DebugDispatchCommand = typeof dispatchCommands[number];
export function isDebugDispatchCommand(value: unknown): value is DebugDispatchCommand {
return dispatchCommands.includes(value as DebugDispatchCommand);
}
async function readJson(url: string, init?: RequestInit): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
@@ -134,9 +141,39 @@ export async function debugHealth(config: UniDeskConfig): Promise<unknown> {
};
}
export async function debugDispatch(config: UniDeskConfig, providerId: string, command: "docker.ps" | "provider.upgrade" | "echo"): Promise<unknown> {
return coreInternalFetch("/api/dispatch", {
method: "POST",
body: { providerId, command, payload: command === "provider.upgrade" ? { source: "cli-debug", mode: "plan" } : { source: "cli-debug" } },
});
async function waitForTask(taskId: string, timeoutMs: number): Promise<unknown> {
const started = Date.now();
let latest: unknown = null;
while (Date.now() - started < timeoutMs) {
latest = coreInternalFetch("/api/tasks?limit=100");
const tasks = (latest as { body?: { tasks?: Array<{ id?: string; status?: string; result?: unknown }> } }).body?.tasks ?? [];
const task = tasks.find((item) => item.id === taskId);
if (task?.status === "succeeded" || task?.status === "failed") return { ok: true, task };
await Bun.sleep(500);
}
return { ok: false, timeoutMs, latest };
}
export async function debugTask(_config: UniDeskConfig, taskId: string): Promise<unknown> {
const tasksResponse = coreInternalFetch("/api/tasks?limit=100");
const tasks = (tasksResponse as { body?: { tasks?: Array<{ id?: string }> } }).body?.tasks ?? [];
const task = taskId === "latest" ? tasks[0] : tasks.find((item) => item.id === taskId);
return { tasksResponse, taskId, task: task ?? null };
}
export async function debugDispatch(
config: UniDeskConfig,
providerId: string,
command: DebugDispatchCommand,
payload?: Record<string, unknown>,
waitMs = 0,
): Promise<unknown> {
const dispatchPayload = payload ?? (command === "provider.upgrade" ? { source: "cli-debug", mode: "plan" } : { source: "cli-debug" });
const dispatch = coreInternalFetch("/api/dispatch", {
method: "POST",
body: { providerId, command, payload: dispatchPayload },
});
const taskId = (dispatch as { body?: { taskId?: string } }).body?.taskId ?? "";
const wait = waitMs > 0 && taskId.length > 0 ? await waitForTask(taskId, waitMs) : null;
return { dispatch, wait };
}
+1
View File
@@ -95,6 +95,7 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
UNIDESK_PROVIDER_UPGRADE_RUNNER_IMAGE: config.providerGateway.upgrade.runnerImage,
UNIDESK_LOG_DIR: logDir,
UNIDESK_LOG_PREFIX: logPrefix,
UNIDESK_HOST_SSH_KEY_DIR: config.sshForwarding.keyDir,
UNIDESK_HOST_SSH_HOST: config.sshForwarding.host,
UNIDESK_HOST_SSH_PORT: String(config.sshForwarding.port),
UNIDESK_HOST_SSH_USER: config.sshForwarding.user,
+2
View File
@@ -331,6 +331,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
await page.getByRole("button", { name: /资源节点/ }).click();
await page.getByRole("button", { name: /资源监控/ }).click();
await page.waitForSelector('[data-testid="node-monitor-page"]', { timeout: 10000 });
await page.locator('[data-testid="node-monitor-page"]').getByRole("button", { name: new RegExp(config.providerGateway.id) }).click();
await page.waitForSelector('[data-testid="metric-chart-cpu"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="metric-chart-memory"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="metric-chart-disk"]', { timeout: 10000 });
@@ -344,6 +345,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
const upgradeControlText = await page.locator('[data-testid="provider-upgrade-control"]').innerText({ timeout: 5000 });
await page.getByRole("button", { name: /Docker 状态/ }).click();
await page.waitForSelector('[data-testid="docker-status-page"]', { timeout: 10000 });
await page.locator('[data-testid="docker-status-page"]').getByRole("button", { name: new RegExp(config.providerGateway.id) }).click();
await page.waitForSelector('[data-testid="docker-container-table"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="database-volume-card"]', { timeout: 10000 });
await page.waitForFunction(() => {
+186
View File
@@ -0,0 +1,186 @@
import { spawn } from "node:child_process";
import { type UniDeskConfig, repoRoot } from "./config";
interface ParsedSshArgs {
remoteCommand: string | null;
}
const sshOptionsWithValue = new Set([
"-B", "-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J", "-L", "-l", "-m", "-O", "-o", "-p", "-Q", "-R", "-S", "-W", "-w",
]);
function parseSshArgs(args: string[]): ParsedSshArgs {
const remote: string[] = [];
let remoteStarted = false;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index] ?? "";
if (remoteStarted) {
remote.push(arg);
continue;
}
if (arg === "--") {
remoteStarted = true;
continue;
}
if (arg.startsWith("-") && arg !== "-") {
if (sshOptionsWithValue.has(arg) && index + 1 < args.length) index += 1;
continue;
}
remoteStarted = true;
remote.push(arg);
}
return { remoteCommand: remote.length === 0 ? null : remote.join(" ") };
}
function brokerSource(): string {
return String.raw`
const open = JSON.parse(process.argv[2] || process.argv[1] || "{}");
const token = process.env.PROVIDER_TOKEN || "";
const url = "ws://127.0.0.1:8080/ws/ssh?token=" + encodeURIComponent(token);
const ws = new WebSocket(url);
let exitCode = 255;
let canSend = false;
let opened = false;
const pending = [];
const openTimer = setTimeout(() => {
if (opened) return;
process.stderr.write("unidesk ssh bridge timed out waiting for provider session\n");
try { ws.close(); } catch {}
process.exit(255);
}, Number(open.openTimeoutMs || 15000));
function send(value) {
const text = JSON.stringify(value);
if (!canSend || ws.readyState !== WebSocket.OPEN) {
pending.push(text);
return;
}
ws.send(text);
}
function flush() {
while (pending.length > 0 && ws.readyState === WebSocket.OPEN) {
ws.send(pending.shift());
}
}
function decodeData(data) {
return typeof data === "string" ? data : Buffer.from(data).toString("utf8");
}
ws.addEventListener("open", () => {
canSend = true;
send({
type: "ssh.open",
providerId: open.providerId,
command: open.command || undefined,
cwd: open.cwd || undefined,
cols: open.cols || 100,
rows: open.rows || 30,
});
flush();
});
ws.addEventListener("message", (event) => {
const message = JSON.parse(decodeData(event.data));
if (message.type === "ssh.data") {
opened = true;
const chunk = Buffer.from(message.data || "", "base64");
if (message.stream === "stderr") process.stderr.write(chunk);
else process.stdout.write(chunk);
return;
}
if (message.type === "ssh.opened") {
opened = true;
clearTimeout(openTimer);
return;
}
if (message.type === "ssh.dispatched") return;
if (message.type === "ssh.error") {
clearTimeout(openTimer);
process.stderr.write(String(message.message || "ssh bridge error") + "\n");
exitCode = 255;
ws.close();
return;
}
if (message.type === "ssh.exit") {
clearTimeout(openTimer);
exitCode = Number.isInteger(message.exitCode) ? message.exitCode : 255;
ws.close();
}
});
ws.addEventListener("close", () => {
process.exit(exitCode);
});
ws.addEventListener("error", () => {
process.stderr.write("unidesk ssh bridge websocket error\n");
process.exit(255);
});
process.stdin.on("data", (chunk) => {
send({ type: "ssh.input", data: Buffer.from(chunk).toString("base64"), encoding: "base64" });
flush();
});
process.stdin.on("end", () => {
send({ type: "ssh.eof" });
flush();
});
`;
}
function terminalSize(): { cols: number; rows: number } {
return {
cols: Number(process.stdout.columns) > 0 ? Number(process.stdout.columns) : 100,
rows: Number(process.stdout.rows) > 0 ? Number(process.stdout.rows) : 30,
};
}
export async function runSsh(config: UniDeskConfig, providerId: string, args: string[]): Promise<number> {
if (!providerId) throw new Error("ssh requires provider id, for example: bun scripts/cli.ts ssh D518");
const parsed = parseSshArgs(args);
const size = terminalSize();
const payload = {
providerId,
command: parsed.remoteCommand,
cols: size.cols,
rows: size.rows,
};
const child = spawn("docker", [
"exec",
"-i",
"unidesk-backend-core",
"bun",
"-e",
brokerSource(),
"--",
JSON.stringify(payload),
], {
cwd: repoRoot,
stdio: ["pipe", "pipe", "pipe"],
});
const rawMode = parsed.remoteCommand === null && process.stdin.isTTY;
if (rawMode) process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.pipe(child.stdin);
child.stdout.pipe(process.stdout);
child.stderr.pipe(process.stderr);
return await new Promise<number>((resolve) => {
const restore = (): void => {
process.stdin.unpipe(child.stdin);
if (rawMode) process.stdin.setRawMode(false);
};
child.on("error", (error) => {
restore();
process.stderr.write(`unidesk ssh failed to start broker: ${error.message}\n`);
resolve(255);
});
child.on("close", (code) => {
restore();
resolve(code ?? 255);
});
});
}