Files
Ruben Fiszel afddfe8445 feat(worker): #ssh directive to run a bash script on a remote SSH host (#9479)
* feat(worker): #ssh directive to run a bash script on a remote SSH host

Add a first-class `#ssh <resource_path>` bash directive that reroutes a
normal bash script to run on a remote host reached over SSH (a
jump/utility node) instead of on the worker, with full parity: typed
positional args in, structured result out, live streamed logs,
cancellation, and remote exit-code propagation.

It mirrors the existing `# sandbox <image>` precedent: the directive is
parsed in handle_bash_job and reroutes to a specialized handler that
reuses handle_child for all execution plumbing.

- windmill-common: BashAnnotations::ssh_target() parser (+ unit test)
  and the ssh_execution_enabled instance setting (off by default)
- windmill-worker: reroute hook in bash_executor + ssh_executor_oss
  shim. OSS returns a clear "enterprise feature" error; the real
  handler lives in ssh_executor_ee.rs (private feature) and is gated by
  a valid enterprise license + the instance setting.
- examples/usecase/ssh-execution-wrapper: the ssh_target resource type,
  a userland wrapper (no-license fallback), and a README documenting
  both paths and the trade-offs vs agent workers.

EE companion: windmill-labs/windmill-ee-private (ee-repo-ref.txt bumped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(worker): ssh host-key opt-in, 0600 key write, instance setting UI

* chore: update ee-repo-ref

* feat(worker): #ssh $arg form to take the ssh target from a job argument

* fix(worker): #ssh token must look like a target; $arg restricted to path strings

* fix(worker): tighten #ssh parser to exact directive; add -- ssh destination guard

* chore: update ee-repo-ref to d45b9a6cbe40f7fe5d322c850c50f64a6980e4f0

This commit updates the EE repository reference after PR #609 was merged in windmill-ee-private.

Previous ee-repo-ref: 2804f1aa8e74b3a7733aeb6f5044d5085193872a

New ee-repo-ref: d45b9a6cbe40f7fe5d322c850c50f64a6980e4f0

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-06-10 15:14:37 +00:00

255 lines
12 KiB
Markdown

SSH execution
=============
Run a self-contained script on a remote host that Windmill cannot place a worker
on, but can reach over SSH (a jump box / utility node).
> ⚠️ **Read this first: prefer agent workers.** For almost every "run code in an
> isolated/segmented environment" need, the recommended answer is an **agent
> worker**. See [When to use what](#when-to-use-what) below.
There are two ways to do this, sharing the same `ssh_target` resource type:
1. **The `#ssh` directive (recommended, enterprise).** Write a *normal* bash
script and add one line — `#ssh <resource_path>` — at the top. The worker
reroutes execution to the remote host with **full parity**: typed positional
args in, structured result out, live streamed logs, cancellation, and the
remote exit code fails the job. This is a first-class backend feature; see
[The `#ssh` directive](#the-ssh-directive) below.
2. **The userland wrapper (no license required).** A reusable Windmill script
(`ssh_exec.sh` / `ssh_exec.py`) that you *call*, passing your remote code as a
string argument. No backend changes, no license — but you lose the editor
experience and structured results. Use it when you can't run the enterprise
image. Documented in [The userland wrapper](#the-userland-wrapper).
What's here
-----------
| File | Purpose |
| --- | --- |
| `ssh_target.resource-type.json` | Resource type: `host`, `port`, `user`, `private_key` (secret), `host_pubkey`, `accept_unknown_host`. Shared by both approaches. |
| `ssh_exec.sh` | The userland wrapper as a Windmill **bash** script. |
| `ssh_exec.py` | Userland wrapper as a Windmill **python** script (interpreter-dispatch table). |
The `#ssh` directive
--------------------
**Enable it (once, as a superadmin):** the feature is enterprise-gated and off by
default. Turn on the `ssh_execution_enabled` instance setting (Superadmin
settings), which requires a valid enterprise license.
**Use it:** create an `ssh_target` resource (see [Setup](#setup)), then write a
bash script with the directive on a leading comment line:
```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
```
The script runs on the remote host exactly as a local bash script would: the
arguments come from the run form, the result is collected the same way
(`result.json` > `result.out` > last stdout line), logs stream live, and a
non-zero remote exit fails the job. Only the *execution location* changes.
**Dynamic target (`#ssh $<arg_name>`):** instead of hardcoding a path, the
directive can name a job argument that supplies the target at call time — for
picking the host from the run form, or fanning out over hosts in a flow forloop:
```bash
#ssh $jump_host
target="$1" # jump_host's position: always received as an empty string
df -h
```
The argument must be an `ssh_target` resource **path string** (with or without
the `$res:` prefix) — inline `ssh_target` objects are rejected, so the target is
always resolved through the runner's resource permissions and a caller can only
route execution to hosts whose resource they can read. Two things to note: the
target argument itself is forwarded to the remote script as an **empty string**
(its resolved value embeds the private key, which must never reach the remote
command line — its position is kept so the other `$1..$n` stay aligned), and
with a dynamic target the *runner* chooses where the code executes (bounded by
those resource permissions), whereas a hardcoded path lets the script author
pin it.
**Host-key pinning** is enforced (`StrictHostKeyChecking=yes`) whenever the
resource's `host_pubkey` is set. An empty `host_pubkey` refuses to run unless the
resource explicitly sets `accept_unknown_host: true`, which falls back to weaker
TOFU (`accept-new`) and logs a warning — development only.
**Parity boundary:** the remote receives the script body and its positional args
only. The Windmill runtime is *not* forwarded — `BASE_INTERNAL_URL`, the `wmill`
client, and reserved `WM_*` variables are unavailable remotely, so in-script
Windmill API callbacks won't work. The same trade-offs as the wrapper apply:
no remote dependency management, no nsjail sandbox, no S3 cache, per-job SSH
overhead. v1 is bash-only.
The userland wrapper
--------------------
The wrapper takes an `ssh_target` resource, a `script_content` string, and a
`language`, then:
1. writes the private key to a `0600` temp file (and a job-local `known_hosts`),
2. opens a single SSH connection (no TTY),
3. streams the script body to the remote host's stdin, where a small bootstrap
`mktemp`s a file, `trap`s its removal on `EXIT`, runs it with the right
interpreter, and exits with the script's exit code,
4. streams stdout/stderr back live and **propagates the remote exit code** so a
failed remote script fails the Windmill job.
```
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 $? │
└────────────────────┘ └─────────────────────────────┘
```
Setup
-----
1. **Create the resource type.** Push it with the CLI:
```bash
wmill resource-type push ssh_target.resource-type.json
```
or recreate it in the UI (Resources → Resource Types) with the same schema.
`private_key` is marked secret (`"password": true`); `host_pubkey` is optional.
2. **Create an `ssh_target` resource** for your jump node. Get `host_pubkey` from
the server (the `keytype key` portion, comment optional):
```bash
ssh-keyscan -t ed25519 your.jump.host # → ssh-ed25519 AAAAC3Nz...
```
3. **Create a script** from `ssh_exec.sh` (bash) or `ssh_exec.py` (python). Mark
the first argument as a resource of type `ssh_target`.
Usage
-----
Call the wrapper with the target, the remote script body, and its language:
```jsonc
{
"ssh_target": "$res:u/me/my_jump_node",
"script_content": "set -euo pipefail\ndf -h\nsystemctl is-active nginx",
"language": "bash"
}
```
```jsonc
{
"ssh_target": "$res:u/me/my_jump_node",
"script_content": "import platform\nprint(platform.platform())",
"language": "python"
}
```
Supported `language` keys: `bash`, `sh`, `python`/`python3`, `node`/`javascript`,
`ruby`, `php`, `perl`. Any other value is passed through as a raw remote
interpreter command. The remote host must already have that interpreter and any
dependencies installed (see tradeoffs).
Design notes (the details that make or break it)
------------------------------------------------
These are deliberate and worth preserving if you adapt the wrapper:
- **Exit-code propagation.** `ssh host cmd` returns the *remote* exit code. The
bash wrapper reads it via `${PIPESTATUS[1]}` and re-`exit`s it; the python
wrapper raises on non-zero. A failed remote script fails the Windmill job.
- **No TTY.** We never pass `-t`/`-tt`. A TTY merges stdout and stderr and
mangles log capture. Enable `-tt` **only** for interactive remote prompts
(e.g. `sudo` asking for a password).
- **Live, unbuffered logs.** `python -u` is used for python; for chatty bash that
buffers when piped, wrap the remote interpreter with `stdbuf -oL` (edit the
dispatch table, e.g. `interp="stdbuf -oL bash"`).
- **Remote cleanup survives failure.** The `trap 'rm -f "$f"' EXIT` is set on the
**remote** side, inside the streamed bootstrap, so the temp file is removed
even if the script errors out.
- **Host-key pinning.** With `host_pubkey` set, the wrapper pins it into a
job-local `known_hosts` and enforces `StrictHostKeyChecking=yes` (non-default
ports use the `[host]:port` form). With it empty, the wrapper refuses to run
unless `accept_unknown_host: true` is set on the resource, which falls back to
`accept-new` (TOFU) and warns — weaker; pin in production.
- **Quoted heredoc.** The remote bootstrap is built with `<<'REMOTE'` so `$f`,
`$?`, `$TMPDIR` are evaluated *remotely*, not expanded on the worker.
- **Body via stdin.** The script body is streamed over stdin, never written to a
local temp file or interpolated into the command line.
- **`--` before the destination.** OpenSSH parses a destination starting with
`-` as an option, so without the separator a crafted `user` like
`-oProxyCommand=...` in the resource would execute a local command on the
worker before host-key validation. Keep the `--` if you adapt the wrapper.
- **Multiple round-trips?** This wrapper makes a single SSH connection. If you
extend it to several `ssh` calls, add
`-o ControlMaster=auto -o ControlPersist=60 -o ControlPath=<job-local>` to
reuse one connection instead of re-authenticating each time.
When to use what
----------------
**Default: agent workers.** A Windmill *agent worker* is a lightweight worker
that runs *inside* the target environment and connects back to the Windmill
server over **outbound HTTP only** (using an `jwt_agent_*` token) — no inbound
ports, no DB access. It keeps everything Windmill workers normally give you:
automatic dependency management, nsjail sandboxing, the S3 binary cache, native
secrets, all languages, and no per-job connection overhead. If you can run a
process in the target environment, use an agent worker.
**Worker-group tags** are the right tool when you *can* place a full worker in
the environment and want to route specific scripts to it.
**This SSH wrapper** is for the narrow case where **both** are true:
- you can only reach a **jump/utility node** over SSH (you cannot place any
worker or agent process there), and
- the scripts are **simple and self-contained** (no Windmill-managed deps).
What you lose with the SSH path
-------------------------------
- **No dependency management.** The remote host must already have the interpreter
*and* every library/tool the script uses. Nothing is installed or locked.
- **No nsjail sandboxing.** The script runs as the SSH user with that user's full
privileges. The jump node becomes a high-value target — scope the key and user
tightly.
- **No S3 / binary cache.** No shared cache of dependencies or artifacts.
- **Per-job SSH overhead.** Each run pays connection + auth latency (mitigable
with ControlMaster only if you make multiple round-trips).
- **No native Windmill integrations on the remote side** — no resource/variable
injection, no `wmill` client, no flow step context beyond what you pass in.
Limitations of this prototype
-----------------------------
- Requires an `ssh` client (and `jq` for the bash variant) on the worker.
- Assumes a self-contained, non-interactive script. No stdin is forwarded to the
remote script (stdin carries the script body).
- Unknown `language` values are passed through verbatim as the remote
interpreter — keep `language` author-controlled, not end-user input.
Tested
------
Both wrappers were exercised against a local `sshd`: success path, remote
exit-code propagation (bash `${PIPESTATUS[1]}`, python raises), clean
stdout/stderr separation, `python -u` interpreter dispatch, host-key pinning
rejecting a wrong key (script never runs), the TOFU opt-in
(`accept_unknown_host: true`) and the refusal without it, and confirmed remote
*and* local temp-file cleanup.