From fea954f9ab87b0a2fa50c92d194b84112aef889f Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 5 May 2026 04:17:32 +0000 Subject: [PATCH] fix: route remote cli through frontend --- AGENTS.md | 2 +- TEST.md | 4 +- docs/reference/cli.md | 8 +- docs/reference/e2e.md | 2 +- docs/reference/provider-gateway.md | 2 +- scripts/cli.ts | 4 +- scripts/src/remote.ts | 315 ++++++++++++++++++++++++++++- scripts/src/ssh.ts | 4 +- 8 files changed, 328 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 681bc367..e02a0d71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 ## CLI - `bun scripts/cli.ts help`:输出所有可用命令的 JSON 索引,详细规范见 `docs/reference/cli.md`。 -- `bun scripts/cli.ts --main-server-ip `:通过 SSH 透传到主 server 执行同一 CLI,便于计算节点自测远程升级和 provider-gateway SSH 透传,详细规范见 `docs/reference/cli.md`。 +- `bun scripts/cli.ts --main-server-ip `:默认通过公网 frontend 登录态远程执行调试与节点自测命令,不要求主 server SSH key,详细规范见 `docs/reference/cli.md`。 - `bun scripts/cli.ts config show`:校验并展示根目录 `config.json`,配置来源规则见 `docs/reference/config.md`。 - `bun scripts/cli.ts check`:运行配置、TypeScript、文件存在性和 Docker Compose 配置检查,测试入口见 `TEST.md`。 - `bun scripts/cli.ts server start`:以异步 job 启动 database、backend-core、frontend、provider-gateway,部署规则见 `docs/reference/deployment.md`。 diff --git a/TEST.md b/TEST.md index a772b60d..fb33f1e8 100644 --- a/TEST.md +++ b/TEST.md @@ -58,7 +58,7 @@ ## T14 Provider Gateway 远程升级 -阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts debug dispatch main-server provider.upgrade`,随后查看任务历史或 `bun scripts/cli.ts debug health`,确认 `provider.upgrade` 通过真实 WebSocket 下发并以 `mode: plan` 成功返回升级计划且计划中包含 `policy: "always-enabled"`;对明确要升级的计算节点,必须再运行 `bun scripts/cli.ts debug dispatch provider.upgrade --mode schedule --wait-ms 15000`,确认任务成功、result 包含 updater 容器信息、节点随后重新上线。在非主 server 的计算节点上,必须使用 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch provider.upgrade --mode schedule --wait-ms 15000` 做同一验证,证明该节点能通过主 server CLI 透传自测自动升级。正式执行升级只能通过前端 `资源监控` 的 `执行升级` 或等价的显式调度完成,不能使用 Host SSH 维护桥作为自动升级通道,也不能通过 `PROVIDER_UPGRADE_ENABLED` 或等价开关禁用远程升级。 +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts debug dispatch main-server provider.upgrade`,随后查看任务历史或 `bun scripts/cli.ts debug health`,确认 `provider.upgrade` 通过真实 WebSocket 下发并以 `mode: plan` 成功返回升级计划且计划中包含 `policy: "always-enabled"`;对明确要升级的计算节点,必须再运行 `bun scripts/cli.ts debug dispatch provider.upgrade --mode schedule --wait-ms 15000`,确认任务成功、result 包含 updater 容器信息、节点随后重新上线。在非主 server 的计算节点上,必须使用 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch provider.upgrade --mode schedule --wait-ms 15000` 做同一验证,证明该节点能通过公网 frontend remote CLI 自测自动升级,且不需要指定 `--main-server-key`。正式执行升级只能通过前端 `资源监控` 的 `执行升级` 或等价的显式调度完成,不能使用 Host SSH 维护桥作为自动升级通道,也不能通过 `PROVIDER_UPGRADE_ENABLED` 或等价开关禁用远程升级。 ## T15 待处理任务可追溯 @@ -70,7 +70,7 @@ ## T17 Provider Gateway Host SSH / WSL SSH 维护桥 -阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认目标 provider-gateway 已只读挂载维护私钥目录到 `/run/host-ssh`,目标宿主或 WSL 的 sshd 已启动且 `authorized_keys` 包含对应公钥;运行 `bun scripts/cli.ts debug dispatch main-server host.ssh --wait-ms 15000`,再运行 `bun scripts/cli.ts debug task latest`,确认任务通过真实 WebSocket 下发、状态为 `succeeded`、result 中 `probeLine` 包含 `UNIDESK_SSH_TEST`、`exitCode` 为 0、`hostSshKeyPresent` 为 true。随后运行 `bun scripts/cli.ts ssh main-server hostname`,确认输出是远端 hostname 且进程 exit code 为 0;再用 `printf 'pwd\nexit\n' | bun scripts/cli.ts ssh main-server` 验证无命令参数时能进入并退出远端登录 shell。对 D518 这类无公网 SSH 的 WSL 节点,使用同一命令替换 Provider ID 为 `D518`,必要时先用 debug dispatch 加 `--cwd /home/ubuntu` 覆盖远端工作目录,只能通过 provider-gateway 自连维护桥验证,不得把主 server 直连节点公网 22 端口作为通过标准;在计算节点本机还必须用 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch host.ssh --wait-ms 15000` 和 `bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh hostname` 自测 remote CLI 透传。 +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认目标 provider-gateway 已只读挂载维护私钥目录到 `/run/host-ssh`,目标宿主或 WSL 的 sshd 已启动且 `authorized_keys` 包含对应公钥;运行 `bun scripts/cli.ts debug dispatch main-server host.ssh --wait-ms 15000`,再运行 `bun scripts/cli.ts debug task latest`,确认任务通过真实 WebSocket 下发、状态为 `succeeded`、result 中 `probeLine` 包含 `UNIDESK_SSH_TEST`、`exitCode` 为 0、`hostSshKeyPresent` 为 true。随后运行 `bun scripts/cli.ts ssh main-server hostname`,确认输出是远端 hostname 且进程 exit code 为 0;再用 `printf 'pwd\nexit\n' | bun scripts/cli.ts ssh main-server` 验证无命令参数时能进入并退出远端登录 shell。对 D518 这类无公网 SSH 的 WSL 节点,使用同一命令替换 Provider ID 为 `D518`,必要时先用 debug dispatch 加 `--cwd /home/ubuntu` 覆盖远端工作目录,只能通过 provider-gateway 自连维护桥验证,不得把主 server 直连节点公网 22 端口作为通过标准;在计算节点本机还必须用 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch host.ssh --wait-ms 15000` 和 `bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh hostname` 自测 remote CLI 透传,命令不得要求 `--main-server-key`。 ## T18 Provider Gateway 版本与自动更新记录 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 94dab824..ee8c5b98 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -5,7 +5,7 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 ## Command Model - `help` 输出命令索引,适合作为交互式入口。 -- `--main-server-ip ` 通过 SSH 登录主 server,在主 server 的 UniDesk 仓库中执行同一个 `bun scripts/cli.ts `,stdout/stderr 和 exit code 原样透传给当前机器。 +- `--main-server-ip ` 默认通过公网 frontend 登录态调用主 server 的同源 API 代理,不要求计算节点持有主 server SSH key;显式提供 `--main-server-key` 或 `--main-server-transport ssh` 时才使用旧 SSH 传输。 - `config show` 读取并校验根目录 `config.json`,不从环境变量、默认值或隐藏文件静默补配置。 - `check` 执行配置校验、文件存在性检查、`scripts/` TypeScript 检查、`src/components/` TypeScript 检查和 Docker Compose 配置检查。 - `server start` 创建异步 job,在后台执行 Docker 构建和启动;命令本身只负责返回 job id、日志路径和启动命令。 @@ -42,8 +42,10 @@ core 只允许声明了 `host.ssh` capability 的 provider 使用 `ssh` 透传 ## Remote Main Server Passthrough -`--main-server-ip` 是一个全局前缀,必须放在需要透传的命令同一次调用中,例如 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health`。本地 CLI 只负责建立 SSH 连接,不读取本机 `config.json` 执行业务逻辑;真实命令在主 server 的 `--main-server-root` 目录中运行,默认目录是 `/root/unidesk`。默认 SSH 用户是 `root`、端口是 `22`;需要覆盖时使用 `--main-server-user`、`--main-server-port` 和 `--main-server-key`。 +`--main-server-ip` 是一个全局前缀,必须放在需要透传的命令同一次调用中,例如 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health`。默认传输是公网 frontend:本地 CLI 读取本仓库 `config.json` 中的 frontend 登录账号密码,登录 `http://:/` 获取 HttpOnly session cookie,然后通过 frontend 的 `/api/*` 同源代理访问 backend-core 内网 API;因此计算节点只需要能访问公网 frontend,不需要主 server SSH key,也不需要打开 backend-core REST API 或 PostgreSQL 端口。 + +默认 frontend 传输支持 `debug health`、`debug dispatch`、`debug task` 和 `ssh `。其中 `ssh` 的 remote frontend 传输使用 `host.ssh` dispatch 执行有界远端命令,适合 `ssh D601 hostname` 这类自测;交互式登录 shell 仍应在主 server 本机 CLI 使用,或显式切换到旧 SSH 传输后在主 server 上执行。若确实需要旧行为,可使用 `--main-server-key ` 或 `--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts `。 计算节点可以用该入口测试自身的远程升级闭环,而不需要在计算节点公开 core REST API 或 database。标准顺序是:先运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health` 确认主 server 看到当前 Provider 在线,且该 Provider labels 中 `unideskCapabilities` 包含 `host.ssh`、`hostSshConfigured=true`、`hostSshKeyPresent=true`;再运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch provider.upgrade --mode schedule --wait-ms 15000` 触发真实 `provider.upgrade`;随后再次运行 `debug health` 确认节点重新上线;最后运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch host.ssh --wait-ms 15000` 和 `bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh hostname` 验证 SSH 透传能力。provider-gateway 新部署或升级后没有完成这组 remote CLI 自测,不能视为交付完成。 -远程透传只接受主 server SSH 信任边界内的 key;不要把 provider token、数据库端口或 backend-core REST API 暴露给计算节点。该入口的目标是复用主 server CLI 的既有鉴权和内网能力,让外部节点像“在主 server 上执行 CLI”一样完成自测。 +远程透传的安全边界是公网 frontend 登录态和 frontend 到 backend-core 的内网代理;不要把 provider token、数据库端口或 backend-core REST API 暴露给计算节点。旧 SSH 传输只作为兼容路径保留,不得把“必须提供主 server SSH key”作为计算节点自测的前置条件。 diff --git a/docs/reference/e2e.md b/docs/reference/e2e.md index ea9a3e4f..55cc90ab 100644 --- a/docs/reference/e2e.md +++ b/docs/reference/e2e.md @@ -47,4 +47,4 @@ Before claiming delivery, run these checks and keep their JSON output or screens When delivery explicitly includes upgrading a compute node such as D601 or D518, the automated E2E plan check is not sufficient. The operator must first bootstrap any legacy provider manually if it cannot yet schedule upgrades, then run `provider.upgrade` with `mode: "schedule"` against that Provider ID, confirm the task succeeds, confirm the node reconnects in the public frontend, and finally verify any required `host.ssh` capability with `bun scripts/cli.ts ssh hostname`. This schedule check is a node-upgrade gate, not a replacement for the standard public frontend Playwright E2E gate. -External compute nodes should run that schedule check through the remote main-server passthrough form: `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch provider.upgrade --mode schedule --wait-ms 15000`. This proves the node can validate itself without direct access to backend-core REST or PostgreSQL. +External compute nodes should run that schedule check through the remote main-server passthrough form: `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch provider.upgrade --mode schedule --wait-ms 15000`. The default remote transport logs in to the public frontend and does not require a main server SSH key; this proves the node can validate itself without direct access to backend-core REST or PostgreSQL. diff --git a/docs/reference/provider-gateway.md b/docs/reference/provider-gateway.md index 400f523a..158d8d27 100644 --- a/docs/reference/provider-gateway.md +++ b/docs/reference/provider-gateway.md @@ -82,7 +82,7 @@ backend-core 可以通过真实 WebSocket 调度向在线 provider 下发 `provi 如果节点已有专用 Compose,优先用节点本地 Compose 手动重建一次:`docker compose --env-file .state/provider-.env -f -p up -d --no-deps --build provider-gateway`。老版 `docker-compose` 可能在重建已存在容器时因为 `ContainerConfig` 兼容问题失败;此时只能移除目标 provider-gateway 容器后重新 `up -d --no-deps provider-gateway`,不得执行 `down -v`、`docker volume rm` 或任何会影响 database 命名卷的命令。如果节点当前只有 `docker run` 部署,则先构建镜像 `docker build -f src/components/provider-gateway/Dockerfile -t unidesk_provider-gateway: .`,再以固定容器名重建:挂载 `/var/run/docker.sock:/var/run/docker.sock`、`/home/ubuntu/unidesk:/workspace:ro`、节点日志目录到 `/var/log/unidesk`,如需 WSL SSH 维护桥还要把只读私钥目录挂载到 `/run/host-ssh`,并使用同一个 `.state/provider-.env` 启动。无论 Compose 还是 `docker run`,容器名和镜像 tag 都必须带 Provider ID,便于 Docker 状态页、任务历史和节点本地排障互相对应。 -手动升级完成后的判定标准固定为主 server 可观测结果,而不是节点容器 `running`:访问公网 frontend `http://74.48.78.17:18081/`,确认该 Provider 在线;随后在任意装有本仓库和主 server SSH key 的计算节点上执行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch provider.upgrade --mode schedule --wait-ms 15000`,确认任务 `succeeded` 且 result 包含 updater 容器信息;最后再次查看 frontend 或执行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health`,确认节点重连、指标恢复、labels 中 `host.ssh` 能力存在。每个 provider-gateway 手动升级后都必须用 remote CLI 再执行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch host.ssh --wait-ms 15000` 和 `bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh hostname`,验证维护桥没有在升级后丢失。 +手动升级完成后的判定标准固定为主 server 可观测结果,而不是节点容器 `running`:访问公网 frontend `http://74.48.78.17:18081/`,确认该 Provider 在线;随后在任意装有本仓库且 `config.json` 含正确 frontend 登录凭据的计算节点上执行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch provider.upgrade --mode schedule --wait-ms 15000`,确认任务 `succeeded` 且 result 包含 updater 容器信息;最后再次查看 frontend 或执行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health`,确认节点重连、指标恢复、labels 中 `host.ssh` 能力存在。每个 provider-gateway 手动升级后都必须用 remote CLI 再执行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch host.ssh --wait-ms 15000` 和 `bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh hostname`,验证维护桥没有在升级后丢失;该 remote CLI 默认走公网 frontend,不需要指定 `--main-server-key`。 ## Host SSH Maintenance Bridge diff --git a/scripts/cli.ts b/scripts/cli.ts index d911c0eb..991a31ff 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -18,7 +18,7 @@ function help(): unknown { output: "json", commands: [ { command: "help", description: "List supported commands." }, - { command: "--main-server-ip [--main-server-user root] [--main-server-key path] ", description: "Pass the command through SSH and execute this CLI on the main server." }, + { command: "--main-server-ip ", description: "Run selected commands through the public frontend API; use --main-server-key only for legacy SSH transport." }, { command: "config show", description: "Validate and print config.json as the single source of truth." }, { command: "check", description: "Run config, TypeScript, file presence, and docker-compose config checks." }, { command: "server start", description: "Fire-and-forget build/start for database, backend-core, frontend, and provider gateway." }, @@ -89,7 +89,7 @@ function latestJobId(): string { async function main(): Promise { if (remoteOptions.host !== null) { - process.exitCode = await runRemoteCli(remoteOptions); + process.exitCode = await runRemoteCli(remoteOptions, readConfig()); return; } diff --git a/scripts/src/remote.ts b/scripts/src/remote.ts index 429ae49e..f025f8c9 100644 --- a/scripts/src/remote.ts +++ b/scripts/src/remote.ts @@ -1,4 +1,7 @@ import { spawn } from "node:child_process"; +import { type UniDeskConfig } from "./config"; +import { type DebugDispatchCommand, isDebugDispatchCommand } from "./debug"; +import { parseSshArgs } from "./ssh"; export interface RemoteCliOptions { host: string | null; @@ -6,14 +9,28 @@ export interface RemoteCliOptions { port: number; projectRoot: string; identityFile: string | null; + transport: "auto" | "frontend" | "ssh"; args: string[]; } +interface FrontendSession { + baseUrl: string; + cookie: string; +} + +interface FetchJsonResult { + ok: boolean; + status?: number; + body?: unknown; + error?: string; +} + const hostOptions = new Set(["--main-server-ip", "--main-server", "--server"]); const userOptions = new Set(["--main-server-user", "--server-user"]); const portOptions = new Set(["--main-server-port", "--server-port"]); const rootOptions = new Set(["--main-server-root", "--server-root"]); const keyOptions = new Set(["--main-server-key", "--server-key"]); +const transportOptions = new Set(["--main-server-transport", "--server-transport"]); function positivePort(raw: string, option: string): number { const value = Number(raw); @@ -27,6 +44,11 @@ function requiredValue(argv: string[], index: number, option: string): string { return value; } +function transportValue(raw: string, option: string): RemoteCliOptions["transport"] { + if (raw === "auto" || raw === "frontend" || raw === "ssh") return raw; + throw new Error(`${option} must be one of: auto, frontend, ssh`); +} + export function extractRemoteCliOptions(argv: string[]): RemoteCliOptions { const rest: string[] = []; const options: RemoteCliOptions = { @@ -35,6 +57,7 @@ export function extractRemoteCliOptions(argv: string[]): RemoteCliOptions { port: 22, projectRoot: "/root/unidesk", identityFile: null, + transport: "auto", args: rest, }; @@ -69,6 +92,11 @@ export function extractRemoteCliOptions(argv: string[]): RemoteCliOptions { index += 1; continue; } + if (transportOptions.has(arg)) { + options.transport = transportValue(requiredValue(argv, index, arg), arg); + index += 1; + continue; + } rest.push(arg); } @@ -79,7 +107,7 @@ function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } -export async function runRemoteCli(options: RemoteCliOptions): Promise { +async function runRemoteCliOverSsh(options: RemoteCliOptions): Promise { if (options.host === null) throw new Error("runRemoteCli requires --main-server-ip or --server"); const remoteArgs = options.args.length === 0 ? ["help"] : options.args; const remoteCommand = [ @@ -118,3 +146,288 @@ export async function runRemoteCli(options: RemoteCliOptions): Promise { child.on("close", (code) => resolve(code ?? 255)); }); } + +function emitRemoteJson(command: string, data: unknown, ok = true): void { + process.stdout.write(`${JSON.stringify({ ok, command, data }, null, 2)}\n`); +} + +function emitRemoteError(command: string, error: unknown): void { + const payload = error instanceof Error + ? { name: error.name, message: error.message, stack: error.stack ?? null } + : { message: String(error) }; + process.stdout.write(`${JSON.stringify({ ok: false, command, error: payload }, null, 2)}\n`); +} + +function commandName(args: string[]): string { + return args.join(" ") || "help"; +} + +function frontendBaseUrl(host: string, config: UniDeskConfig): string { + if (host.startsWith("http://") || host.startsWith("https://")) return host.replace(/\/+$/, ""); + if (/:\d+$/.test(host)) return `http://${host}`; + return `http://${host}:${config.network.frontend.port}`; +} + +async function readJson(url: string, init?: RequestInit, timeoutMs = 8000): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { ...init, signal: controller.signal }); + const text = await res.text(); + let body: unknown = null; + try { + body = text.length > 0 ? JSON.parse(text) : null; + } catch { + body = { text }; + } + return { ok: res.ok, status: res.status, body }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } finally { + clearTimeout(timer); + } +} + +async function loginFrontend(host: string, config: UniDeskConfig): Promise { + const baseUrl = frontendBaseUrl(host, config); + const res = await fetch(`${baseUrl}/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: config.auth.username, password: config.auth.password }), + }); + const body = await res.text(); + if (!res.ok) { + throw new Error(`frontend login failed via ${baseUrl}: status=${res.status} body=${body.slice(0, 300)}`); + } + const cookie = res.headers.get("set-cookie")?.split(";")[0] ?? ""; + if (cookie.length === 0) throw new Error(`frontend login via ${baseUrl} did not return a session cookie`); + return { baseUrl, cookie }; +} + +async function frontendJson(session: FrontendSession, path: string, init?: RequestInit, timeoutMs = 8000): Promise { + const headers = new Headers(init?.headers); + headers.set("cookie", session.cookie); + if (init?.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json"); + return readJson(`${session.baseUrl}${path}`, { ...init, headers }, timeoutMs); +} + +function stringOption(args: string[], 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 numberOption(args: string[], name: string, defaultValue: number): number { + const raw = stringOption(args, name); + if (raw === undefined) return defaultValue; + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`); + return value; +} + +function jsonOption(args: string[], name: string): Record | undefined { + const raw = stringOption(args, 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; +} + +function dispatchPayload(args: string[], command: DebugDispatchCommand): Record { + const explicit = jsonOption(args, "--payload-json") ?? {}; + if (command === "provider.upgrade") { + return { source: "cli-remote", mode: stringOption(args, "--mode") ?? stringOption(args, "--upgrade-mode") ?? "plan", ...explicit }; + } + if (command === "host.ssh") { + const sshCommand = stringOption(args, "--ssh-command"); + return { + source: "cli-remote", + mode: sshCommand === undefined ? "probe" : "exec", + ...(sshCommand === undefined ? {} : { command: sshCommand }), + ...(stringOption(args, "--cwd") === undefined ? {} : { cwd: stringOption(args, "--cwd") }), + ...(args.includes("--timeout-ms") ? { timeoutMs: numberOption(args, "--timeout-ms", 8000) } : {}), + ...explicit, + }; + } + return { source: "cli-remote", ...explicit }; +} + +function summarizeSystemStatus(response: FetchJsonResult): FetchJsonResult { + const body = response.body as { systemStatuses?: Array>; ok?: boolean } | null; + const systemStatuses = (body?.systemStatuses ?? []).map((item) => { + const current = (item.current ?? {}) as Record; + const history = Array.isArray(item.history) ? item.history : []; + return { + providerId: item.providerId, + name: item.name, + nodeStatus: item.nodeStatus, + updatedAt: item.updatedAt, + current: item.current === null || item.current === undefined ? null : { + ok: current.ok, + collectedAt: current.collectedAt, + cpu: current.cpu, + memory: current.memory, + disk: current.disk, + }, + historyPreview: history.slice(-8), + historyCount: history.length, + }; + }); + return { ...response, body: { ok: body?.ok === true, systemStatuses } }; +} + +function summarizeDockerStatus(response: FetchJsonResult): FetchJsonResult { + const body = response.body as { dockerStatuses?: Array>; ok?: boolean } | null; + const dockerStatuses = (body?.dockerStatuses ?? []).map((item) => { + const status = (item.dockerStatus ?? {}) as Record; + const containers = Array.isArray(status.containers) ? status.containers : []; + return { + providerId: item.providerId, + name: item.name, + nodeStatus: item.nodeStatus, + updatedAt: item.updatedAt, + dockerStatus: { + ok: status.ok, + socketPresent: status.socketPresent, + collectedAt: status.collectedAt, + counts: status.counts, + daemon: status.daemon, + containersPreview: containers.slice(0, 8), + }, + }; + }); + return { ...response, body: { ok: body?.ok === true, dockerStatuses } }; +} + +async function waitForFrontendTask(session: FrontendSession, taskId: string, timeoutMs: number): Promise { + const started = Date.now(); + let latest: unknown = null; + while (Date.now() - started < timeoutMs) { + latest = await frontendJson(session, "/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 }; +} + +async function remoteHealth(session: FrontendSession, config: UniDeskConfig): Promise { + return { + transport: "frontend", + frontendPublic: await readJson(`${session.baseUrl}/health`), + providerIngressPublic: await readJson(`http://${new URL(session.baseUrl).hostname}:${config.network.providerIngress.port}/health`), + overviewInternal: await frontendJson(session, "/api/overview"), + nodesInternal: await frontendJson(session, "/api/nodes"), + systemStatusInternal: summarizeSystemStatus(await frontendJson(session, "/api/nodes/system-status?limit=24")), + dockerStatusInternal: summarizeDockerStatus(await frontendJson(session, "/api/nodes/docker-status")), + publicExposureBoundary: { + coreHostPort: { port: config.network.core.port, expected: "not-exposed" }, + databaseHostPort: { port: config.network.database.port, expected: "not-exposed" }, + }, + }; +} + +async function remoteDebugDispatch(session: FrontendSession, config: UniDeskConfig, args: string[]): Promise { + const third = args[2]; + const fourth = args[3]; + const providerId = isDebugDispatchCommand(third) ? config.providerGateway.id : third ?? config.providerGateway.id; + const commandArg = isDebugDispatchCommand(third) ? third : fourth; + const dispatchCommand = isDebugDispatchCommand(commandArg) ? commandArg : "docker.ps"; + const dispatch = await frontendJson(session, "/api/dispatch", { + method: "POST", + body: JSON.stringify({ providerId, command: dispatchCommand, payload: dispatchPayload(args, dispatchCommand) }), + }); + const taskId = (dispatch as { body?: { taskId?: string } }).body?.taskId ?? ""; + const waitMs = numberOption(args, "--wait-ms", 0); + const wait = waitMs > 0 && taskId.length > 0 ? await waitForFrontendTask(session, taskId, waitMs) : null; + return { transport: "frontend", dispatch, wait }; +} + +async function remoteDebugTask(session: FrontendSession, args: string[]): Promise { + const taskId = args[2] ?? "latest"; + const tasksResponse = await frontendJson(session, "/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 { transport: "frontend", tasksResponse, taskId, task: task ?? null }; +} + +async function runRemoteSshOverFrontend(session: FrontendSession, providerId: string | undefined, args: string[]): Promise { + if (!providerId) throw new Error("remote ssh requires provider id, for example: bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh D601 hostname"); + const parsed = parseSshArgs(args); + if (parsed.remoteCommand === null) { + process.stderr.write("remote frontend transport supports ssh remote commands only; pass a command such as: ssh D601 hostname\n"); + return 255; + } + const dispatch = await frontendJson(session, "/api/dispatch", { + method: "POST", + body: JSON.stringify({ + providerId, + command: "host.ssh", + payload: { source: "cli-remote-ssh", mode: "exec", command: parsed.remoteCommand, timeoutMs: 15000 }, + }), + }); + const taskId = (dispatch as { body?: { taskId?: string } }).body?.taskId ?? ""; + if (!dispatch.ok || taskId.length === 0) { + process.stderr.write(`${JSON.stringify(dispatch, null, 2)}\n`); + return 255; + } + const wait = await waitForFrontendTask(session, taskId, 20_000); + const task = (wait as { task?: { status?: string; result?: Record; message?: string } }).task; + const result = task?.result ?? {}; + const stdout = typeof result.stdout === "string" ? result.stdout : ""; + const stderr = typeof result.stderr === "string" ? result.stderr : ""; + if (stdout.length > 0) process.stdout.write(stdout); + if (stderr.length > 0) process.stderr.write(stderr); + if (task?.status !== "succeeded") { + if (stdout.length === 0 && stderr.length === 0) process.stderr.write(`${JSON.stringify({ taskId, task }, null, 2)}\n`); + return typeof result.exitCode === "number" ? result.exitCode : 255; + } + return typeof result.exitCode === "number" ? result.exitCode : 0; +} + +async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDeskConfig): Promise { + if (options.host === null) throw new Error("runRemoteCli requires --main-server-ip or --server"); + const args = options.args.length === 0 ? ["help"] : options.args; + const name = commandName(args); + try { + const session = await loginFrontend(options.host, config); + const [top, sub] = args; + if (top === "help" || top === "--help" || top === "-h") { + emitRemoteJson(name, { + transport: "frontend", + baseUrl: session.baseUrl, + commands: ["debug health", "debug dispatch", "debug task", "ssh "], + }); + return 0; + } + if (top === "debug" && sub === "health") { + emitRemoteJson(name, await remoteHealth(session, config)); + return 0; + } + if (top === "debug" && sub === "dispatch") { + emitRemoteJson(name, await remoteDebugDispatch(session, config, args)); + return 0; + } + if (top === "debug" && sub === "task") { + emitRemoteJson(name, await remoteDebugTask(session, args)); + return 0; + } + if (top === "ssh") { + return await runRemoteSshOverFrontend(session, sub, args.slice(2)); + } + throw new Error(`remote frontend transport does not support command: ${name}`); + } catch (error) { + emitRemoteError(name, error); + return 1; + } +} + +export async function runRemoteCli(options: RemoteCliOptions, config: UniDeskConfig): Promise { + if (options.host === null) throw new Error("runRemoteCli requires --main-server-ip or --server"); + const useSsh = options.transport === "ssh" || (options.transport === "auto" && options.identityFile !== null); + if (useSsh) return runRemoteCliOverSsh(options); + return runRemoteCliOverFrontend(options, config); +} diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 452db2d4..72add537 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -1,7 +1,7 @@ import { spawn } from "node:child_process"; import { type UniDeskConfig, repoRoot } from "./config"; -interface ParsedSshArgs { +export interface ParsedSshArgs { remoteCommand: string | null; } @@ -9,7 +9,7 @@ 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 { +export function parseSshArgs(args: string[]): ParsedSshArgs { const remote: string[] = []; let remoteStarted = false; for (let index = 0; index < args.length; index += 1) {