diff --git a/AGENTS.md b/AGENTS.md index c842bc70..7a53a977 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,9 +10,10 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts server start`:以异步 job 启动 database、backend-core、frontend、provider-gateway,部署规则见 `docs/reference/deployment.md`。 - `bun scripts/cli.ts server status`:查询固定端口、容器状态、健康检查和访问 URL,判定标准见 `docs/reference/deployment.md`。 - `bun scripts/cli.ts server logs`:分页返回文件日志与 Docker 日志尾部,日志规则见 `docs/reference/observability.md`。 +- `bun scripts/cli.ts ssh [ssh-like args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥打开近似原生 ssh 的交互会话或远端命令,使用规则见 `docs/reference/cli.md` 和 `docs/reference/provider-gateway.md`。 - `bun scripts/cli.ts server stop`:以异步 job 停止固定 Compose 项目中的全部 UniDesk 服务,停止后用 `server status` 复核。 - `bun scripts/cli.ts job list` / `bun scripts/cli.ts job status latest`:查询 `.state/jobs/` 中的异步任务状态,job 机制见 `docs/reference/cli.md`。 -- `bun scripts/cli.ts debug health` / `bun scripts/cli.ts debug dispatch`:通过 Docker 内网 core、真实 HTTP、WebSocket、系统指标和 Docker 状态流程调试健康检查与任务下发,调试规则见 `docs/reference/cli.md`。 +- `bun scripts/cli.ts debug health` / `bun scripts/cli.ts debug dispatch` / `bun scripts/cli.ts debug task`:通过 Docker 内网 core、真实 HTTP、WebSocket、系统指标、Docker 状态和 Host SSH 维护桥流程调试健康检查、任务下发与任务结果,调试规则见 `docs/reference/cli.md`。 - `bun scripts/cli.ts e2e run`:验证公网 frontend/provider ingress、内网 core/database、provider-gateway 自接入、资源指标曲线、Docker 状态快照、provider.upgrade 预检和 Playwright 登录页面,验收规则见 `docs/reference/e2e.md`。 ## Runtime @@ -20,7 +21,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun`:TypeScript 运行时固定使用 Bun,组件入口和 CLI 都直接运行 `.ts` 文件,约束见 `docs/reference/config.md`。 - `docker-compose.yml`:主 server 统一编排 core、frontend、database 和本机 provider gateway,且只公开 frontend/provider ingress,服务拓扑见 `docs/reference/deployment.md`。 - `src/components/frontend`:前端源码固定使用 TypeScript + React,采用高信息密度工业控制台设计,资源节点含任务管理器风格资源监控与 Docker Desktop 风格状态页,界面规则见 `docs/reference/frontend.md`。 -- `src/components/provider-gateway`:当前主 server `74.48.78.17` 也作为 provider gateway 接入 UniDesk,外部节点通过 `ws://74.48.78.17:18082/ws/provider` 接入,部署与 Playwright 公网前端验证方法见 `docs/reference/provider-gateway.md`。 +- `src/components/provider-gateway`:当前主 server `74.48.78.17` 也作为 provider gateway 接入 UniDesk,外部节点通过 `ws://74.48.78.17:18082/ws/provider` 接入,并可配置维护专用 Host SSH / WSL SSH 桥,部署与 Playwright 公网前端验证方法见 `docs/reference/provider-gateway.md`。 - `docs/reference/e2e.md`:交付前必须执行的自测门禁、Playwright 登录与 JSON 展示断言、数据库命名卷持久化要求。 ## Architecture Docs diff --git a/TEST.md b/TEST.md index 94e4571e..3ddf72a8 100644 --- a/TEST.md +++ b/TEST.md @@ -67,3 +67,7 @@ ## T16 任务历史诊断信息 阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts e2e run`,确认 `frontend:task-history-diagnostics` passed;再登录 frontend,进入 `任务调度 / 任务历史`,确认每个任务行都能看到状态、任务命令和 id、Provider、任务耗时、载荷摘要、诊断信息、更新时间和 `查看原始JSON` 按钮。失败任务必须在默认表格中显示失败原因以及 exit code、timeout、previous status 等关键字段,完整 result 只能点击 `查看原始JSON` 查看。 + +## 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 端口作为通过标准。 diff --git a/docker-compose.yml b/docker-compose.yml index 7adb53ca..e66836dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -130,6 +130,7 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock - ${UNIDESK_PROVIDER_UPGRADE_HOST_PROJECT_ROOT}:${UNIDESK_PROVIDER_UPGRADE_WORKSPACE_PATH}:ro + - ${UNIDESK_HOST_SSH_KEY_DIR}:/run/host-ssh:ro - ${UNIDESK_LOG_DIR}:/var/log/unidesk extra_hosts: - "host.docker.internal:host-gateway" diff --git a/docs/reference/arch.md b/docs/reference/arch.md index b24178e6..ab705b2a 100644 --- a/docs/reference/arch.md +++ b/docs/reference/arch.md @@ -48,7 +48,7 @@ - The main server never initiates connections to nodes, perfectly adapting to environments without public IP and behind NAT - Interaction with Local Execution Environment - The primary path for automated task dispatching and execution is via the local Docker socket - - Access to the local environment via WSL SSH is reserved solely as an auxiliary path for emergency maintenance and troubleshooting + - Access to the local environment via WSL SSH is reserved solely as an auxiliary path for emergency maintenance and troubleshooting, exposed only as bounded `host.ssh` probe/exec tasks - Automating task deployment or dispatching through the WSL SSH channel is forbidden - Connection Management - When registering, a node carries an authentication token to verify its identity and declares resources such as GPU/CPU diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 00b6e2e7..c58f2e62 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -11,8 +11,9 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 - `server stop` 创建异步 job,在后台停止固定 Compose project 中的全部 UniDesk 服务。 - `server status` 查询公开端口、内部端口、Compose 容器、core/frontend/provider/database 健康检查和访问 URL。 - `server logs` 返回 `logs/` 文件日志和 Docker 容器日志的尾部,默认限制输出大小,避免日志爆炸。 +- `ssh [ssh-like args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;无后续参数时进入远端登录 shell,有后续参数时按 ssh 远端命令体验执行并返回远端 exit code。 - `job list` 与 `job status` 查询 `.state/jobs/` 文件系统状态,是异步命令的可观测入口。 -- `debug health` 与 `debug dispatch` 走真实内部 core、WebSocket、数据库、provider、系统指标和 Docker 状态流程,只用于开发调试,不写入 `TEST.md` 的正式验收步骤。 +- `debug health`、`debug dispatch` 与 `debug task` 走真实内部 core、WebSocket、数据库、provider、系统指标、Docker 状态和 Host SSH 维护桥流程,只用于开发调试,不写入 `TEST.md` 的正式验收步骤。 - `e2e run` 使用 publicHost 派生的公开 frontend/provider ingress URL,并通过 Docker 内网验证 core API、PostgreSQL、provider self-connection、系统指标曲线、Docker 状态快照、provider.upgrade 预检和 Playwright 前端页面,是交付前的自动化 E2E 门禁。 ## Async Job State @@ -25,4 +26,12 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 ## Debug Contract -`debug` 子命令必须复用真实模块与真实端点,禁止维护平行实现。`debug health` 会摘要展示 `/api/nodes/system-status` 和 `/api/nodes/docker-status`,避免输出完整快照造成信息爆炸。`debug dispatch` 会在 backend-core 容器内调用内部 `/api/dispatch`,core 再通过 WebSocket 将 `docker.ps`、`provider.upgrade` 或 `echo` 任务下发给 provider gateway,因此它可以验证核心调度闭环,同时不需要公开 core REST API。 +`debug` 子命令必须复用真实模块与真实端点,禁止维护平行实现。`debug health` 会摘要展示 `/api/nodes/system-status` 和 `/api/nodes/docker-status`,避免输出完整快照造成信息爆炸。`debug dispatch` 会在 backend-core 容器内调用内部 `/api/dispatch`,core 再通过 WebSocket 将 `docker.ps`、`provider.upgrade`、`host.ssh` 或 `echo` 任务下发给 provider gateway,因此它可以验证核心调度闭环,同时不需要公开 core REST API。`host.ssh` 默认使用 `mode: "probe"` 做短超时维护桥自检;需要执行明确命令时使用 `--ssh-command` 进入 `mode: "exec"`,并配合 `--wait-ms` 和 `debug task` 查看 stdout、stderr、exitCode 与 probeLine。 + +## SSH Command + +`ssh [ssh-like args...]` 是面向人的终端透传入口,不包装 JSON 输出。CLI 会在宿主机启动一个 `docker exec -i unidesk-backend-core bun -e ...` broker,broker 只连接 backend-core 的 Docker 内网 `/ws/ssh`,core 再把 stdin/stdout/stderr 流量通过目标 provider 的既有 WebSocket 转发到 provider-gateway,provider-gateway 最终执行维护用 `ssh -tt` 连接宿主或 WSL sshd。该入口不新增 core 公网端口,不暴露 database,也不改变 frontend/provider ingress 之外的公网边界。 + +`bun scripts/cli.ts ssh D518` 应表现为登录 D518 WSL 的 shell;`bun scripts/cli.ts ssh D518 hostname` 应像 `ssh D518 hostname` 一样只输出远端命令结果并返回远端 exit code。Provider ID 前的目标选择由 UniDesk 节点清单决定,`-p`、`-i`、`-l`、`-o` 等传统 ssh 传输参数由 provider-gateway 部署配置统一管理,CLI 会兼容性消费这些参数但不会覆盖节点侧维护桥配置。 + +core 只允许声明了 `host.ssh` capability 的 provider 使用 `ssh` 透传或 `host.ssh` dispatch;旧 provider 不支持该能力时必须快速失败并输出错误,不能把未知命令误判成 `echo` 成功。 diff --git a/docs/reference/config.md b/docs/reference/config.md index 7ead8c3a..ebf81226 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -18,6 +18,10 @@ TypeScript 运行时固定为 Bun。根目录 CLI、backend-core、frontend 和 `providerGateway.metrics.diskPath` 指定资源监控页的硬盘采样路径,默认是 `/`。`providerGateway.upgrade` 定义远程升级 provider-gateway 所需的 Compose project、service、仓库挂载路径、派生 env 文件和 updater runner 镜像;这些字段由 CLI 写入 `.state/docker-compose.env`,provider-gateway 只通过 WebSocket 接受 `provider.upgrade` 调度,不从隐藏环境或默认值静默补齐。 +## SSH Forwarding + +`sshForwarding` 定义 provider-gateway 维护专用 Host SSH / WSL SSH 桥的显式配置。CLI 会把 `sshForwarding.keyDir` 写入 `.state/docker-compose.env` 的 `UNIDESK_HOST_SSH_KEY_DIR`,Compose 将该目录只读挂载到 provider-gateway 的 `/run/host-ssh`,并把 `sshForwarding.host`、`sshForwarding.port`、`sshForwarding.user` 映射为 `HOST_SSH_HOST`、`HOST_SSH_PORT`、`HOST_SSH_USER`。目录中必须存在 `id_ed25519` 私钥且权限收紧,provider-gateway 才会把 `hostSshKeyPresent` 上报为 true,并允许 `host.ssh` 维护探测;该桥只用于故障诊断和 WSL 维护,不替代 Docker socket 调度。 + ## Compose Env Generation Docker Compose 本身不读取 JSON,因此 CLI 会从 `config.json` 生成 `.state/docker-compose.env`。该文件是派生状态,不应手写;如需改端口、token、provider 标签、登录凭据或主机名,应修改 `config.json` 后重新运行 CLI。CLI 会在保留当前日志前缀的同时刷新新增配置键,避免旧 env 文件遗漏字段。 diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index 1b741905..57f5f6f8 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -7,7 +7,7 @@ - `database` 使用 `postgres:16-alpine`,数据保存到 named volume `unidesk_pgdata_10gb`,初始化 SQL 位于 `src/components/database/init/`。 - `backend-core` 是无状态核心服务,提供 Docker 内网 REST API、provider ingress WebSocket、任务调度入口和数据库访问层。 - `frontend` 是唯一公开 Web 控制台,提供登录、从 TSX 转译出的 React 应用资产和到 backend-core 的同源代理。 -- `provider-gateway` 是当前主 server 的本机计算节点代理,通过 WebSocket 主动连到 provider ingress,挂载 `/var/run/docker.sock` 作为自动任务执行主路径,并周期性上报系统资源指标与 Docker daemon 状态。 +- `provider-gateway` 是当前主 server 的本机计算节点代理,通过 WebSocket 主动连到 provider ingress,挂载 `/var/run/docker.sock` 作为自动任务执行主路径,并周期性上报系统资源指标与 Docker daemon 状态;维护用 Host SSH / WSL SSH 私钥目录只读挂载到 `/run/host-ssh`,不得作为自动任务调度主路径。 ## Public Exposure Boundary diff --git a/docs/reference/provider-gateway.md b/docs/reference/provider-gateway.md index 26d8313e..7fbeb563 100644 --- a/docs/reference/provider-gateway.md +++ b/docs/reference/provider-gateway.md @@ -10,7 +10,7 @@ Provider Gateway 是计算节点侧容器。它只主动连出到主 server 暴 当前主 server 公网 IP 是 `74.48.78.17`,`config.json` 中的 `network.publicHost` 必须保持为该地址;公网 frontend 入口是 `http://74.48.78.17:18081/`,provider gateway 对外接入入口是 `ws://74.48.78.17:18082/ws/provider`,provider ingress 健康检查是 `http://74.48.78.17:18082/health`。主 server 本机 provider 由根目录 `docker-compose.yml` 的 `provider-gateway` 服务启动,容器内使用 Docker 内网地址 `ws://backend-core:8081/ws/provider` 自接入;外部计算节点部署 provider-gateway 时必须改用公网 provider ingress URL,并复用 `config.json` / `.state/docker-compose.env` 中的 provider token、心跳间隔和重连参数。 -计算节点部署 provider-gateway 的最小方法是:准备可运行 `unidesk_provider-gateway` 镜像的 Docker 环境,为节点分配唯一 `PROVIDER_ID` 与可读 `PROVIDER_NAME`,设置 `PROVIDER_SERVER_URL=ws://74.48.78.17:18082/ws/provider`、`PROVIDER_TOKEN`、`PROVIDER_LABELS_JSON`、`HEARTBEAT_INTERVAL_MS`、`RECONNECT_BASE_MS` 和 `RECONNECT_MAX_MS`,并挂载 `/var/run/docker.sock:/var/run/docker.sock` 作为 Docker 状态采集、任务执行和远程升级的唯一自动化通道。需要支持 `provider.upgrade` 的节点还必须设置 `PROVIDER_UPGRADE_*` 环境变量,把节点上的 UniDesk 仓库只读挂载到 `PROVIDER_UPGRADE_WORKSPACE_PATH`,并确保升级命令只重建 `provider-gateway` service,不影响 database、backend-core、frontend。 +计算节点部署 provider-gateway 的最小方法是:准备可运行 `unidesk_provider-gateway` 镜像的 Docker 环境,为节点分配唯一 `PROVIDER_ID` 与可读 `PROVIDER_NAME`,设置 `PROVIDER_SERVER_URL=ws://74.48.78.17:18082/ws/provider`、`PROVIDER_TOKEN`、`PROVIDER_LABELS_JSON`、`HEARTBEAT_INTERVAL_MS`、`RECONNECT_BASE_MS` 和 `RECONNECT_MAX_MS`,并挂载 `/var/run/docker.sock:/var/run/docker.sock` 作为 Docker 状态采集、任务执行和远程升级的唯一自动化通道。需要支持 `provider.upgrade` 的节点还必须设置 `PROVIDER_UPGRADE_*` 环境变量,把节点上的 UniDesk 仓库只读挂载到 `PROVIDER_UPGRADE_WORKSPACE_PATH`,并确保升级命令只重建 `provider-gateway` service,不影响 database、backend-core、frontend。需要维护桥连接 WSL 的节点必须额外设置 `HOST_SSH_HOST=host.docker.internal`、`HOST_SSH_PORT=22`、`HOST_SSH_USER=`、`HOST_SSH_KEY=/run/host-ssh/id_ed25519`、`HOST_REMOTE_CWD=/home/`,并把只含维护私钥的宿主目录只读挂载到 `/run/host-ssh`。 ## WSL Compute Node Deployment @@ -36,7 +36,7 @@ provider-gateway 部署是否成功必须以 UniDesk frontend 中可见的 Provi WSL 节点还应补充一次真实调度验证:向该 `PROVIDER_ID` 下发 `docker.ps`,任务必须从 `dispatched` 进入 `succeeded`,并在结果中看到 WSL Docker daemon 返回的容器列表;对于容器化运行的 provider-gateway,列表中通常应包含 `unidesk-provider-gateway-`。这一步可以同时证明 provider WebSocket、服务端任务路由、节点侧 Docker socket 和结果回传链路都已贯通。 -自动化验证必须使用 Playwright 访问公网 frontend,而不是在容器内直接调 core API 代替浏览器验收。标准命令是 `bun scripts/cli.ts e2e run`;该命令会让 Playwright 打开公网 `http://74.48.78.17:18081/`、登录、抓取页面中的 Provider 信息和 `查看原始JSON` 内容,并检查 Provider 自接入、资源指标、Docker 状态和 `provider.upgrade` 预检。外部新增节点的人工验收应复用同一套前端路径:先确认 Provider 信息出现在节点清单,再确认资源监控和 Docker 状态页面有该节点的数据,最后通过任务调度向该 Provider 下发 `echo` 或 `docker.ps` 并在任务历史中查看耗时、状态和失败原因。 +自动化验证必须使用 Playwright 访问公网 frontend,而不是在容器内直接调 core API 代替浏览器验收。标准命令是 `bun scripts/cli.ts e2e run`;该命令会让 Playwright 打开公网 `http://74.48.78.17:18081/`、登录、抓取页面中的 Provider 信息和 `查看原始JSON` 内容,并检查 Provider 自接入、资源指标、Docker 状态和 `provider.upgrade` 预检。外部新增节点的人工验收应复用同一套前端路径:先确认 Provider 信息出现在节点清单,再确认资源监控和 Docker 状态页面有该节点的数据,最后通过任务调度向该 Provider 下发 `echo`、`docker.ps` 或维护专用 `host.ssh` probe,并在任务历史中查看耗时、状态、stdout/stderr 摘要和失败原因。 ## Provider Ingress @@ -60,4 +60,10 @@ backend-core 可以通过真实 WebSocket 调度向在线 provider 下发 `provi ## Host SSH Maintenance Bridge -宿主 SSH 转发只作为应急维护辅助路径,不用于自动任务调度。实现参考 `../web-terminal` 的经验:容器内使用只读挂载的私钥,通过 `ssh -tt` 主动连接宿主 sshd,并设置 `StrictHostKeyChecking=accept-new`、`ServerAliveInterval` 和 `ServerAliveCountMax`。本仓库保留 `src/components/provider-gateway/scripts/host-ssh-shell.sh` 作为维护桥接脚本,默认 Compose 不挂载私钥,避免把 SSH 路径误用为调度通道。 +宿主 SSH / WSL SSH 转发只作为应急维护辅助路径,不用于自动计算任务调度。实现参考 `../web-terminal` 的经验:容器内使用只读挂载的私钥,主动连接宿主或 WSL sshd,并设置 `BatchMode=yes`、`StrictHostKeyChecking=accept-new`、`ServerAliveInterval=20` 和 `ServerAliveCountMax=3`。主 server Compose 会把 `config.json` 的 `sshForwarding.keyDir` 只读挂载为 `/run/host-ssh`,provider 标签会上报 `hostSshConfigured`、`hostSshKeyPresent` 和 `hostSshTarget`,便于在前端节点清单确认维护桥是否具备条件。 + +维护桥通过真实 WebSocket dispatch 暴露为 `host.ssh` 命令。默认 payload 使用 `mode: "probe"`,远端只执行一个短命令并返回 `UNIDESK_SSH_TEST user=... host=... bridge=host.ssh cwd=...`;需要人工诊断时可以显式使用 `mode: "exec"` 与 `command` 字段执行有界命令。所有 `host.ssh` 执行都必须有超时,stdout/stderr 在 task result 中截断展示;自动升级和普通任务仍必须使用 Docker socket 与 `provider.upgrade`,不得把 WSL SSH 维护桥当成调度通道。 + +面向人的终端入口是 `bun scripts/cli.ts ssh [ssh-like args...]`。无后续参数时打开远端登录 shell,有后续参数时执行远端命令并返回远端 exit code;该入口走 backend-core 内网 `/ws/ssh` broker 和 provider 既有 WebSocket,不新增公网 core 端口。传统 ssh 传输参数由 provider-gateway 环境变量统一控制,CLI 只负责把 Provider ID 后的远端命令和终端 stdin/stdout/stderr 透传过去。 + +验证 WSL SSH 桥时,先在目标 WSL 中启动 sshd 并确保维护公钥写入目标用户的 `authorized_keys`,再确认目标 provider 注册 labels 中 `unideskCapabilities` 包含 `host.ssh`。运行 `bun scripts/cli.ts debug dispatch host.ssh --wait-ms 15000` 后,结果应在 `debug task latest` 或前端任务历史中显示 `status: succeeded`、`probeLine` 含 `UNIDESK_SSH_TEST`、`exitCode: 0`,并且目标节点 labels 中 `hostSshKeyPresent` 为 true;随后运行 `bun scripts/cli.ts ssh hostname` 验证近似原生 ssh 的远端命令体验。如果 D518 这类 WSL 节点没有公网 SSH 入口,也必须通过这个 provider-gateway 自连维护桥完成验证,而不是要求主 server 直接连节点公网 22 端口;旧版 provider 未声明 `host.ssh` 时必须先升级 provider-gateway,否则 core 会拒绝 SSH 透传。 diff --git a/docs/reference/repo-tree.md b/docs/reference/repo-tree.md index 389cc913..7ec0a6a6 100644 --- a/docs/reference/repo-tree.md +++ b/docs/reference/repo-tree.md @@ -58,7 +58,7 @@ - package.json - tsconfig.json - Dockerfile - - src/index.ts (WebSocket client, heartbeat, system/Docker telemetry, Docker adapter, provider.upgrade handler) + - src/index.ts (WebSocket client, heartbeat, system/Docker telemetry, Docker adapter, provider.upgrade handler, Host SSH / WSL SSH maintenance probe) - scripts/host-ssh-shell.sh (Optional maintenance-only SSH bridge) - database/ (PostgreSQL initialization and configuration) - config/postgresql.conf diff --git a/scripts/cli.ts b/scripts/cli.ts index 369df5f4..be1a708b 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -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 [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 [--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 ", 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 | 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; +} + +function dispatchPayload(command: DebugDispatchCommand): Record { + 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 { 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 { 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; } } diff --git a/scripts/src/debug.ts b/scripts/src/debug.ts index faad2916..9d71f57a 100644 --- a/scripts/src/debug.ts +++ b/scripts/src/debug.ts @@ -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 { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 5000); @@ -134,9 +141,39 @@ export async function debugHealth(config: UniDeskConfig): Promise { }; } -export async function debugDispatch(config: UniDeskConfig, providerId: string, command: "docker.ps" | "provider.upgrade" | "echo"): Promise { - 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 { + 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 { + 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, + waitMs = 0, +): Promise { + 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 }; } diff --git a/scripts/src/docker.ts b/scripts/src/docker.ts index 3402a5a4..adbf5619 100644 --- a/scripts/src/docker.ts +++ b/scripts/src/docker.ts @@ -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, diff --git a/scripts/src/e2e.ts b/scripts/src/e2e.ts index 59c8a9eb..164607c0 100644 --- a/scripts/src/e2e.ts +++ b/scripts/src/e2e.ts @@ -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(() => { diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts new file mode 100644 index 00000000..452db2d4 --- /dev/null +++ b/scripts/src/ssh.ts @@ -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 { + 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((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); + }); + }); +} diff --git a/src/components/backend-core/src/index.ts b/src/components/backend-core/src/index.ts index 6524255b..e45a2a30 100644 --- a/src/components/backend-core/src/index.ts +++ b/src/components/backend-core/src/index.ts @@ -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; @@ -35,6 +47,7 @@ type SqlClient = ReturnType; const recentLogs: unknown[] = []; const activeProviders = new Map(); +const activeSshClients = new Map(); 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 { 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 { + if (!dbReady) return []; + const rows = await sql>` + 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).unideskCapabilities; + return Array.isArray(capabilities) ? capabilities.filter((item): item is string => typeof item === "string") : []; +} + +async function providerSupports(providerId: string, capability: string): Promise { + const capabilities = await providerCapabilities(providerId); + return capabilities.includes(capability); +} + async function upsertDockerStatus(providerId: string, status: JsonValue, collectedAt: string): Promise { 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 { 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 { async function dispatchTask(req: Request): Promise { 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) : {}; 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 { return jsonResponse({ ok: true, taskId, status: "dispatched", providerOnline: true }); } -async function route(req: Request): Promise { +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 { + 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): Promise { + 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): Promise { 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): Promise({ 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({ diff --git a/src/components/frontend/src/app.tsx b/src/components/frontend/src/app.tsx index 65559cc6..26a6c891 100644 --- a/src/components/frontend/src/app.tsx +++ b/src/components/frontend/src/app.tsx @@ -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) })), diff --git a/src/components/provider-gateway/src/index.ts b/src/components/provider-gateway/src/index.ts index baac60f4..c4c69088 100644 --- a/src/components/provider-gateway/src/index.ts +++ b/src/components/provider-gateway/src/index.ts @@ -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; + openedAt: number; +} + +interface HostSshStdin { + write(chunk: Uint8Array): unknown; + end(): unknown; +} + +const hostSshSessions = new Map(); + 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, key: string): string | null { + const value = payload[key]; + return typeof value === "string" && value.length > 0 ? value : null; +} + +function payloadTimeoutMs(payload: Record): 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)}...` : 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): Promise { + 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 | null): Promise { + 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 { 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): 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); }); diff --git a/src/components/shared/src/index.ts b/src/components/shared/src/index.ts index 312bd121..bc088f5a 100644 --- a/src/components/shared/src/index.ts +++ b/src/components/shared/src/index.ts @@ -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; } +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; } +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 );