news 2026/9/14 19:01:28

Windmill SSH 远程执行指南:用 `ssh` 指令与 userland wrapper 在跳板机上运行脚本

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Windmill SSH 远程执行指南:用 `ssh` 指令与 userland wrapper 在跳板机上运行脚本

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资源类型定义:hostportuserprivate_key(secret)、host_pubkeyaccept_unknown_host。两种方案共用
ssh_exec.shuserland wrapper 的 Windmillbash脚本版本
ssh_exec.pyuserland wrapper 的 Windmillpython脚本版本(含解释器分发表)

两种方案共用同一个ssh_target资源类型,区别在于执行方式:

  1. #ssh指令(推荐,企业版):写一个普通的 bash 脚本,仅在首行加一行#ssh <resource_path>。Worker 会把执行重路由到远程主机,且保持全对等体验:类型化的位置参数传入、结构化结果返回、日志实时流式输出、任务可取消、远程退出码决定任务成败。这是一等公民的后端特性,参见下文#ssh指令。
  2. 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 中,字段如下:

字段类型必填默认值说明
hoststringSSH 跳板/工具节点的主机名或 IP
portinteger22SSH 端口
userstringSSH 登录用户
private_keystringPEM 编码的私钥,用于认证。在 schema 中标记为"password": true,即作为 secret 存储
host_pubkeystring""服务端主机公钥行,用于 known_hosts 固定(如'ssh-ed25519 AAAAC3Nz...',可通过ssh-keyscan -t ed25519 <host>获取)。设置后强制StrictHostKeyChecking=yes;为空时除非显式设置accept_unknown_host: true,否则拒绝执行
accept_unknown_hostbooleanfalse允许在未固定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_URLwmill客户端、保留的WM_*变量在远程均不可用,因此脚本内的 Windmill API 回调不会工作。与 wrapper 相同的取舍:远程无依赖管理、无 nsjail 沙箱、无 S3 缓存、每次任务有 SSH 连接开销。v1 仅支持 bash。

userland wrapper

当无法使用企业版镜像时,wrapper 是完整的替代路径。它接收一个ssh_target资源、一个script_content字符串和一个language,然后执行四个步骤:

  1. 将私钥写入0600权限的临时文件(并生成任务局部的known_hosts);
  2. 建立单条SSH 连接(不带 TTY);
  3. 将脚本主体通过远端 stdin 流式传入——远端一个小型 bootstrap 用mktemp创建文件、trapEXIT时删除它、用正确的解释器运行它,并以脚本的退出码退出;
  4. 实时流式回传 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 设置步骤

  1. 创建资源类型:用 CLI 推送:

    wmill resource-type push ssh_target.resource-type.json

    或在 UI 中(Resources → Resource Types)用相同 schema 重建。private_key标记为 secret("password": true);host_pubkey可选。

  2. 为跳板机创建ssh_target资源。用如下命令从服务器获取host_pubkeykeytype key部分,注释可选):

    ssh-keyscan -t ed25519 your.jump.host # → ssh-ed25519 AAAAC3Nz...
  3. 创建脚本:从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键:bashshpython/python3node/javascriptrubyphpperl任何其它值会作为原始远端解释器命令透传。远端主机必须已安装对应解释器及脚本所需的全部依赖(参见取舍部分)。源码中 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 18:55:36

微信小程序音乐播放器源码解析:从页面架构到后台播放

简介&#xff1a;基于微信小程序的音乐播放器源码是一份完整的小程序实战项目&#xff0c;面向零基础及有经验的开发者&#xff0c;既可用于学习&#xff0c;也可作为快速搭建音乐播放功能的框架。项目覆盖小程序架构的核心环节&#xff0c;包括WXML/WXSS页面结构设计、JavaScr…

作者头像 李华
网站建设 2026/9/14 18:55:26

al-folio 如何部署到 Netlify?

al-folio 如何部署到 Netlify&#xff1f; 【免费下载链接】al-folio A beautiful, simple, clean, and responsive Jekyll theme for academics 项目地址: https://gitcode.com/GitHub_Trending/al/al-folio al-folio 是面向学术主页的 Jekyll 主题&#xff0c;默认的部…

作者头像 李华
网站建设 2026/9/14 18:54:52

Haystack Agent Pack 实战指南:构建 Advanced RAG 与 Deep Research Agent

Haystack Agent Pack 实战指南&#xff1a;构建 Advanced RAG 与 Deep Research Agent 【免费下载链接】haystack Open-source AI orchestration framework for building context-engineered, production-ready LLM applications. Design modular pipelines and agent workflow…

作者头像 李华
网站建设 2026/9/14 18:54:14

HoRain云--Python asyncio 异步编程实战:从协程到高并发爬虫

1. 同步 vs 异步同步爬虫按顺序请求&#xff0c;耗时累加。异步爬虫在等待网络响应时切换任务&#xff0c;大幅提升吞吐量。2. 协程基础python复制下载import asyncioasync def hello():print("Hello")await asyncio.sleep(1)print("World")asyncio.run(he…

作者头像 李华
网站建设 2026/9/14 18:52:19

两阶段鲁棒优化在微电网调度中的Matlab实现

1. 项目概述&#xff1a;两阶段鲁棒微网优化调度的核心逻辑 微电网作为分布式能源系统的关键载体&#xff0c;其调度优化一直面临风光出力波动、负荷变化等不确定性的挑战。传统随机规划方法依赖精确概率分布&#xff0c;而鲁棒优化则通过构建不确定性集合来规避风险。两阶段鲁…

作者头像 李华