mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
afddfe8445
* 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>
114 lines
5.3 KiB
Bash
Executable File
114 lines
5.3 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Windmill SSH execution wrapper (bash)
|
|
# --------------------------------------------------------------------------
|
|
# Runs a self-contained script on a remote host reachable over SSH from the
|
|
# Windmill worker, streaming stdout/stderr back live and propagating the
|
|
# REMOTE exit code so a failed remote script fails the Windmill job.
|
|
#
|
|
# This is a userland prototype: it makes NO backend changes. See README.md for
|
|
# when to use it (only-a-jump-node reachable, self-contained scripts) and why
|
|
# agent workers are the recommended default.
|
|
#
|
|
# Windmill positional bash arguments:
|
|
# 1. ssh_target resource of type `ssh_target` (Windmill passes it as JSON)
|
|
# 2. script_content the body of the script to run on the remote host
|
|
# 3. language interpreter key: bash|sh|python|node|ruby|php|perl
|
|
# (default: bash; anything else is treated as a raw remote
|
|
# interpreter command)
|
|
#
|
|
# Worker requirements: `ssh` client and `jq` must be installed on the worker.
|
|
|
|
ssh_target="$1"
|
|
script_content="$2"
|
|
language="${3:-bash}"
|
|
|
|
set -euo pipefail
|
|
umask 077
|
|
|
|
command -v jq >/dev/null || { echo "FATAL: jq is required on the worker" >&2; exit 127; }
|
|
command -v ssh >/dev/null || { echo "FATAL: ssh client is required on the worker" >&2; exit 127; }
|
|
|
|
# --- parse the ssh_target resource -----------------------------------------
|
|
# `jq -e` exits non-zero when the field is null/absent, so a missing required
|
|
# field fails fast with a clear message.
|
|
host=$(jq -er '.host' <<<"$ssh_target") || { echo "FATAL: ssh_target.host missing" >&2; exit 2; }
|
|
user=$(jq -er '.user' <<<"$ssh_target") || { echo "FATAL: ssh_target.user missing" >&2; exit 2; }
|
|
private_key=$(jq -er '.private_key' <<<"$ssh_target") || { echo "FATAL: ssh_target.private_key missing" >&2; exit 2; }
|
|
port=$(jq -r '.port // 22' <<<"$ssh_target")
|
|
host_pubkey=$(jq -r '.host_pubkey // empty' <<<"$ssh_target")
|
|
accept_unknown_host=$(jq -r '.accept_unknown_host // false' <<<"$ssh_target")
|
|
|
|
# --- interpreter dispatch table --------------------------------------------
|
|
# `python3 -u` forces unbuffered output so logs stream live. For chatty bash
|
|
# scripts that buffer, wrap the remote interpreter with `stdbuf -oL` (see README).
|
|
case "$language" in
|
|
bash) interp="bash" ;;
|
|
sh) interp="sh" ;;
|
|
python|python3) interp="python3 -u" ;;
|
|
node|javascript) interp="node" ;;
|
|
ruby) interp="ruby" ;;
|
|
php) interp="php" ;;
|
|
perl) interp="perl" ;;
|
|
*) interp="$language" ;; # passthrough: raw remote interpreter command
|
|
esac
|
|
|
|
# --- private key -> 0600 temp file + job-local known_hosts ------------------
|
|
keyfile=$(mktemp "${TMPDIR:-/tmp}/wmssh_key.XXXXXX")
|
|
known_hosts=$(mktemp "${TMPDIR:-/tmp}/wmssh_kh.XXXXXX")
|
|
cleanup() { rm -f "$keyfile" "$known_hosts"; }
|
|
trap cleanup EXIT
|
|
|
|
printf '%s\n' "$private_key" >"$keyfile" # trailing newline: some keys require it
|
|
chmod 600 "$keyfile"
|
|
|
|
# --- host-key handling ------------------------------------------------------
|
|
ssh_opts=(
|
|
-o BatchMode=yes
|
|
-o ConnectTimeout=15
|
|
-o "UserKnownHostsFile=$known_hosts"
|
|
-p "$port"
|
|
-i "$keyfile"
|
|
)
|
|
if [ -n "$host_pubkey" ]; then
|
|
# Pin the server key: non-default ports use the `[host]:port` known_hosts form.
|
|
if [ "$port" = "22" ]; then
|
|
printf '%s %s\n' "$host" "$host_pubkey" >"$known_hosts"
|
|
else
|
|
printf '[%s]:%s %s\n' "$host" "$port" "$host_pubkey" >"$known_hosts"
|
|
fi
|
|
ssh_opts+=( -o StrictHostKeyChecking=yes )
|
|
elif [ "$accept_unknown_host" = "true" ]; then
|
|
echo "WARN: ssh_target.host_pubkey is empty; using TOFU (accept-new) because accept_unknown_host=true. Pin host_pubkey for production." >&2
|
|
ssh_opts+=( -o StrictHostKeyChecking=accept-new )
|
|
else
|
|
echo "FATAL: ssh_target.host_pubkey is empty. Pin the host key (ssh-keyscan -t ed25519 $host) or set accept_unknown_host=true to allow TOFU (insecure against MITM)." >&2
|
|
exit 2
|
|
fi
|
|
|
|
# --- remote bootstrap (runs ON the remote host) ----------------------------
|
|
# Single-quoted heredoc: NOTHING is expanded locally. $f / $? / $TMPDIR are
|
|
# all evaluated remotely. The script body arrives on the remote's stdin.
|
|
remote_bootstrap=$(cat <<'REMOTE'
|
|
set -u
|
|
f=$(mktemp "${TMPDIR:-/tmp}/wmssh_job.XXXXXX") || exit 1
|
|
trap 'rm -f "$f"' EXIT # remote-side cleanup, survives script failure
|
|
cat >"$f" # read the streamed script body from stdin
|
|
@@INTERP@@ "$f" # execute with the chosen interpreter
|
|
exit $? # propagate the remote exit code
|
|
REMOTE
|
|
)
|
|
# NOTE: `interp` is only controlled for the known dispatch keys above — the
|
|
# `*)` case passes `language` through verbatim into the remote bootstrap, so
|
|
# keep `language` author-controlled, never end-user input (see README).
|
|
remote_bootstrap=${remote_bootstrap//@@INTERP@@/$interp}
|
|
|
|
# --- execute ---------------------------------------------------------------
|
|
# - no -t/-tt: keep stdout and stderr separate for clean log capture
|
|
# (add `-tt` ONLY when you need an interactive TTY, e.g. sudo password prompts)
|
|
# - body is streamed via stdin so it never touches a local temp file
|
|
# - PIPESTATUS[1] is ssh's exit code == the remote script's exit code
|
|
set +e
|
|
printf '%s\n' "$script_content" | ssh "${ssh_opts[@]}" -- "$user@$host" "$remote_bootstrap"
|
|
rc=${PIPESTATUS[1]}
|
|
exit "$rc"
|