Windmill SSH 远程执行指南:用#ssh指令与 userland wrapper 在跳板机上运行脚本
【免费下载链接】windmillOpen-source developer platform to power your entire infra and turn scripts into webhooks, workflows and UIs. Fastest workflow engine (13x vs Airflow). Open-source alternative to Retool and Temporal.项目地址: https://gitcode.com/GitHub_Trending/wi/windmill
导读
本文基于 Windmill 仓库中的 examples/usecase/ssh-execution-wrapper 示例,讲解如何在 Windmill 无法部署 Worker、但能通过 SSH 访问的远程主机(跳板机/工具节点)上执行脚本。你将掌握两种实现路径:企业版的一等公民特性#ssh指令(全对等执行体验),以及无需任何许可的 userland wrapper(ssh_exec.sh/ssh_exec.py),并理解其资源类型设计、主机密钥固定机制、退出码传播等底层细节,从而为自己的"隔离环境执行"需求做出正确的技术选型。
重要前提:对于几乎所有"在隔离/分段环境中运行代码"的需求,Windmill 官方推荐的首选方案是agent worker,而非本文的 SSH 路径。SSH wrapper 只适用于"只能通过 SSH 到达跳板机、且脚本简单自包含"的窄场景,详见下文 何时用哪种方案。
方案总览:一条共享的ssh_target资源,两种执行路径
仓库中 examples/usecase/ssh-execution-wrapper 目录提供了一套完整的示例,包含三个文件:
| 文件 | 用途 |
|---|---|
ssh_target.resource-type.json | 资源类型定义:host、port、user、private_key(secret)、host_pubkey、accept_unknown_host。两种方案共用 |
ssh_exec.sh | userland wrapper 的 Windmillbash脚本版本 |
ssh_exec.py | userland wrapper 的 Windmillpython脚本版本(含解释器分发表) |
两种方案共用同一个ssh_target资源类型,区别在于执行方式:
#ssh指令(推荐,企业版):写一个普通的 bash 脚本,仅在首行加一行#ssh <resource_path>。Worker 会把执行重路由到远程主机,且保持全对等体验:类型化的位置参数传入、结构化结果返回、日志实时流式输出、任务可取消、远程退出码决定任务成败。这是一等公民的后端特性,参见下文#ssh指令。- userland wrapper(无需许可):一个可复用的 Windmill 脚本(
ssh_exec.sh/ssh_exec.py),由你调用它,并把远程代码作为字符串参数传入。无需后端改动、无需 license,但会失去编辑器体验和结构化结果。在无法运行企业版镜像时使用,参见下文 userland wrapper。
何时用哪种方案:agent worker 优先
Windmill 的官方决策建议非常明确——默认使用 agent worker:
- Agent worker:一个轻量级 Worker,运行在目标环境内部,仅通过出站 HTTP(使用
jwt_agent_*token)回连 Windmill server。无需入站端口、无需数据库访问。它保留了 Windmill Worker 的全部能力:自动依赖管理、nsjail 沙箱、S3 二进制缓存、原生 secrets、全语言支持、无每次任务的连接开销。只要能在目标环境里跑一个进程,就用 agent worker。 - Worker-group tags(Worker 组标签):当你能在目标环境中放置一个完整 Worker、并希望把特定脚本路由过去时,这是正确的工具。
- 本文的 SSH wrapper:仅在同时满足以下两个条件时使用——
- 你只能通过 SSH 到达跳板机/工具节点(无法在那里放置任何 Worker 或 agent 进程);
- 脚本简单且自包含(不依赖 Windmill 托管的依赖)。
ssh_target资源类型:字段与安全语义
两种方案共享的资源类型 schema 定义在 ssh_target.resource-type.json 中,字段如下:
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
host | string | ✅ | — | SSH 跳板/工具节点的主机名或 IP |
port | integer | — | 22 | SSH 端口 |
user | string | ✅ | — | SSH 登录用户 |
private_key | string | ✅ | — | PEM 编码的私钥,用于认证。在 schema 中标记为"password": true,即作为 secret 存储 |
host_pubkey | string | — | "" | 服务端主机公钥行,用于 known_hosts 固定(如'ssh-ed25519 AAAAC3Nz...',可通过ssh-keyscan -t ed25519 <host>获取)。设置后强制StrictHostKeyChecking=yes;为空时除非显式设置accept_unknown_host: true,否则拒绝执行 |
accept_unknown_host | boolean | — | false | 允许在未固定host_pubkey时连接,采用首次信任(StrictHostKeyChecking=accept-new,即 TOFU)。对 MITM 不安全,仅供开发使用 |
关键安全设计点:
- 私钥永远以 secret 存储(
"password": true),不会进入日志、不会出现在任务参数明文里。 - 主机密钥固定(host-key pinning)是默认安全基线:一旦设置了
host_pubkey,wrapper 会将其写入任务局部的known_hosts并强制StrictHostKeyChecking=yes。若host_pubkey为空,wrapper 会拒绝运行,除非资源显式设置accept_unknown_host: true(此时降级为较弱的 TOFUaccept-new并打印告警——仅限开发环境)。 - 非默认端口使用 known_hosts 的
[host]:port形式,例如[your.jump.host]:2222 ssh-ed25519 AAAAC3Nz...。
#ssh指令(Enterprise)
开启:一次性的 Superadmin 设置
该特性是企业版门控(enterprise-gated)且默认关闭。需要以 superadmin 身份在 Superadmin settings 中开启ssh_execution_enabled实例设置,且要求有效的企业版 license。该设置项在源码中定义于 backend/windmill-common/src/global_settings.rs:
// Enables the `#ssh <resource>` directive that reroutes bash execution to a // remote host over SSH (enterprise feature). Off by default. See // windmill-worker/src/ssh_executor_ee.rs. pub const SSH_EXECUTION_SETTING: &str = "ssh_execution_enabled";在开源(非企业)版本中,对应的执行函数是一个明确报错的 stub:位于 backend/windmill-worker/src/ssh_executor_oss.rs 的handle_ssh_bash_job会返回错误 "SSH execution (#ssh) is an enterprise feature. Use the enterprise image, or the userland SSH wrapper in examples/usecase/ssh-execution-wrapper/."——这也从源码层面印证了文档对两种路径的划分。
使用:一行指令重路由执行
创建好ssh_target资源(见下文 Setup)后,写一个 bash 脚本,在引导注释行放置指令:
#ssh f/infra/jump_node # ^ reroutes this script to run on the host described by the # ssh_target resource at f/infra/jump_node Service="$1" # typed positional args work as usual systemctl is-active "$Service" echo "{\"service\": \"$Service\", \"checked\": true}" # last stdout line = result该脚本在远程主机上的运行方式与本地 bash 脚本完全一致:参数来自运行表单,结果收集方式相同(result.json>result.out> 最后一行 stdout),日志实时流式输出,远程非零退出码会使任务失败。唯一改变的是执行位置。从后端实现看,bash 执行器在 backend/windmill-worker/src/bash_executor.rs 中通过BashAnnotations::ssh_target(content)提取该指令,进而调用handle_ssh_bash_job走 SSH 执行分支。
动态目标:#ssh $<arg_name>
除了硬编码资源路径,指令还可以引用一个任务参数,在调用时提供目标——用于从运行表单挑选主机,或在 flow 的 forloop 中遍历多台主机:
#ssh $jump_host target="$1" # jump_host's position: always received as an empty string df -h这里有几点需要特别注意:
- 该参数必须是
ssh_target资源的路径字符串(带或不带$res:前缀均可);内联的ssh_target对象会被拒绝,因此目标总是经由 runner 的资源权限解析,调用者只能把执行路由到其有权限读取的资源所描述的主机。 - 目标参数本身会以空字符串转发给远程脚本(其解析值内嵌私钥,绝不能出现在远程命令行上);它的位置会被保留,以保证其它
$1..$n对齐不错位。 - 语义差异:动态目标由runner决定代码在哪里执行(受资源权限约束);硬编码路径则由脚本作者固定执行位置。
对等边界(Parity boundary)
远程端只收到脚本主体 + 位置参数。Windmill 运行时不会被转发——BASE_INTERNAL_URL、wmill客户端、保留的WM_*变量在远程均不可用,因此脚本内的 Windmill API 回调不会工作。与 wrapper 相同的取舍:远程无依赖管理、无 nsjail 沙箱、无 S3 缓存、每次任务有 SSH 连接开销。v1 仅支持 bash。
userland wrapper
当无法使用企业版镜像时,wrapper 是完整的替代路径。它接收一个ssh_target资源、一个script_content字符串和一个language,然后执行四个步骤:
- 将私钥写入
0600权限的临时文件(并生成任务局部的known_hosts); - 建立单条SSH 连接(不带 TTY);
- 将脚本主体通过远端 stdin 流式传入——远端一个小型 bootstrap 用
mktemp创建文件、trap在EXIT时删除它、用正确的解释器运行它,并以脚本的退出码退出; - 实时流式回传 stdout/stderr,并传播远端退出码,使远端脚本失败即 Windmill 任务失败。
执行架构
Windmill worker Remote jump node ┌────────────────────┐ ┌─────────────────────────────┐ │ ssh_exec.sh │ ssh (no -t) │ sh -c <bootstrap> │ │ key → 0600 tmp │ ───────────────▶ │ f=$(mktemp) │ │ known_hosts pin │ body on stdin │ trap 'rm -f $f' EXIT │ │ printf body | ssh │ ────────────────▶│ cat > $f │ │ │ ◀─────────────── │ <interp> $f (live logs) │ │ exit = ssh rc │ remote rc │ exit $? │ └────────────────────┘ └─────────────────────────────┘源码级关键设计(这些细节决定成败)
ssh_exec.sh 与 ssh_exec.py 中的每个设计点都是刻意的,改编时值得保留:
- 退出码传播:
ssh host cmd返回的是远端退出码。bash 版通过${PIPESTATUS[1]}读取并重新exit;python 版在非零时抛异常。远端脚本失败 → Windmill 任务失败。 - 无 TTY:从不传
-t/-tt。TTY 会把 stdout 和 stderr 合并,破坏日志捕获。只有需要交互式远程提示(如sudo询问密码)时才启用-tt。 - 实时无缓冲日志:python 使用
python3 -u;对管道传输时会缓冲的"话痨型" bash 脚本,可把远端解释器包上stdbuf -oL(修改分发表,如interp="stdbuf -oL bash")。 - 远端清理在失败时依然生效:
trap 'rm -f "$f"' EXIT设置在远端(流式 bootstrap 内部),因此即使脚本出错,临时文件也会被删除。README 的测试部分确认了远端与本地临时文件清理均被验证。 - 主机密钥固定:设置
host_pubkey时固定进任务局部known_hosts并强制StrictHostKeyChecking=yes(非默认端口用[host]:port形式);为空时拒绝运行,除非accept_unknown_host: true(TOFU,生产环境必须固定)。 - 引号 heredoc:远端 bootstrap 用
<<'REMOTE'构建,保证$f、$?、$TMPDIR在远端求值,而不是在 worker 上被展开。 - Body 走 stdin:脚本主体通过 stdin 流式传输,绝不写入本地临时文件、绝不插值进命令行。
--放在目标前:OpenSSH 会把以-开头的 destination 解析为选项,若不使用分隔符,资源中被精心构造的user(如-oProxyCommand=...)会在主机密钥校验前于 worker 上执行本地命令。改编时必须保留--。- 多次往返?:本 wrapper 只建立单条 SSH 连接。若扩展为多次
ssh调用,应添加-o ControlMaster=auto -o ControlPersist=60 -o ControlPath=<job-local>复用连接,避免每次重新认证。
Setup 设置步骤
创建资源类型:用 CLI 推送:
wmill resource-type push ssh_target.resource-type.json或在 UI 中(Resources → Resource Types)用相同 schema 重建。
private_key标记为 secret("password": true);host_pubkey可选。为跳板机创建
ssh_target资源。用如下命令从服务器获取host_pubkey(keytype key部分,注释可选):ssh-keyscan -t ed25519 your.jump.host # → ssh-ed25519 AAAAC3Nz...创建脚本:从
ssh_exec.sh(bash)或ssh_exec.py(python)创建 Windmill 脚本,并把第一个参数标记为ssh_target类型的资源。
Usage 调用示例
调用 wrapper,传入目标、远端脚本主体与语言:
{ "ssh_target": "$res:u/me/my_jump_node", "script_content": "set -euo pipefail\ndf -h\nsystemctl is-active nginx", "language": "bash" }{ "ssh_target": "$res:u/me/my_jump_node", "script_content": "import platform\nprint(platform.platform())", "language": "python" }支持的language键:bash、sh、python/python3、node/javascript、ruby、php、perl。任何其它值会作为原始远端解释器命令透传。远端主机必须已安装对应解释器及脚本所需的全部依赖(参见取舍部分)。源码中 bash 版的分发表位于 ssh_exec.sh 的case "$language"分支(python 版为 ssh_exec.py 的INTERPRETERS字典),其中python/python3统一映射为python3 -u以强制无缓冲输出。
走 SSH 路径会失去什么
- 无依赖管理:远端主机必须已具备解释器以及脚本用到的每个库/工具。没有任何安装或锁定。
- 无 nsjail 沙箱:脚本以 SSH 用户的身份、以该用户的完整权限运行。跳板机会成为高价值目标——务必严格限定密钥与用户的权限范围。
- 无 S3 / 二进制缓存:没有共享的依赖或产物缓存。
- 每次任务的 SSH 开销:每次运行都付出连接 + 认证延迟(只有多次往返时才能用 ControlMaster 缓解)。
- 远端无原生 Windmill 集成:无资源/变量注入、无
wmill客户端、无 flow 步骤上下文(除你显式传入的外)。
该原型的局限性
- worker 上需要
ssh客户端(bash 版还需要jq)。 - 假定脚本自包含、非交互;stdin 不会被转发给远端脚本(stdin 承载脚本主体)。
- 未知
language值会原样透传为远端解释器——务必让language由作者控制,而非终端用户输入。
测试情况
README 记录了两种 wrapper 均针对本地sshd验证过:成功路径、远端退出码传播(bash${PIPESTATUS[1]}、python 抛异常)、干净的 stdout/stderr 分离、python -u解释器分发、主机密钥固定对错误密钥的拒绝(脚本完全不执行)、TOFU 可选开启(accept_unknown_host: true)及其缺失时的拒绝,以及远端和本地临时文件清理的确认。
总结与选型建议
本文给出了在 Windmill 无法放置 Worker 的跳板机上执行自包含脚本的完整方案:优先评估 agent worker(能力最全、开销最低);能放完整 Worker 就用 worker-group tags 路由;只有"仅 SSH 可达 + 脚本自包含"同时成立时,才选用本示例的 SSH 路径。在此前提下,企业版用户优先使用#ssh指令(编辑体验与结构化结果俱全),无法使用企业版镜像的部署则使用 ssh_exec.sh / ssh_exec.py 这份无许可的 userland wrapper,并严格遵循其安全设计:固定主机密钥、私钥以 secret 存储、language保持作者可控、保留--分隔符与远端trap清理。
【免费下载链接】windmillOpen-source developer platform to power your entire infra and turn scripts into webhooks, workflows and UIs. Fastest workflow engine (13x vs Airflow). Open-source alternative to Retool and Temporal.项目地址: https://gitcode.com/GitHub_Trending/wi/windmill
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考