142 lines
6.2 KiB
Bash
142 lines
6.2 KiB
Bash
#!/usr/bin/env bash
|
|
# ps-encoded.sh — dispatch PowerShell through quote-mangling layers safely.
|
|
#
|
|
# THE point of this helper: a PS payload that traverses CommandLineToArgvW
|
|
# (curl.exe args, plink, ScreenConnect's command box, RMM->cmd.exe) gets its
|
|
# embedded double-quotes stripped and its UNC backslashes halved — the single
|
|
# most-repeated friction class in errorlog.md (ref=feedback_windows_quote_stripping,
|
|
# 9+ hits). -EncodedCommand (UTF-16LE base64) has NO quotes, backslashes, or
|
|
# dollar signs to mangle, so the script arrives byte-exact. Write the script
|
|
# to a FILE (or stdin), let this helper encode + deliver it.
|
|
#
|
|
# Usage:
|
|
# ps-encoded.sh encode <script.ps1|-> print the one-liner for
|
|
# ScreenConnect / plink / any paste
|
|
# ps-encoded.sh rmm <agent-uuid> <script.ps1|-> dispatch via GuruRMM + poll
|
|
# [--timeout <sec>] agent-side timeout_seconds (default 120)
|
|
# [--user-session] context: user_session (WTS-impersonated desktop user)
|
|
# [--no-wait] dispatch only; print command id, skip polling
|
|
# [--force] override the size refusal (see below)
|
|
#
|
|
# Size guards (agent chokes on ~7KB command bodies; encoding inflates ~2.67x):
|
|
# encoded > 4000 chars -> [WARNING]; encoded > 6000 chars -> refuse unless
|
|
# --force (split the script, or stage it on the endpoint and run the file).
|
|
#
|
|
# Exit codes: 0 ok; 1 usage/encode error; 2 size refusal; 3 dispatch/poll failure.
|
|
|
|
set -u
|
|
ROOT="${CLAUDETOOLS_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
|
_logerr() { bash "$ROOT/.claude/scripts/log-skill-error.sh" "ps-encoded" "$@" >/dev/null 2>&1 || true; }
|
|
|
|
usage() { sed -n '2,26p' "$0"; exit 1; }
|
|
|
|
read_script() { # $1 = file path or "-"
|
|
if [ "$1" = "-" ]; then cat
|
|
elif [ -f "$1" ]; then cat "$1"
|
|
else echo "[ERROR] script file not found: $1" >&2; exit 1
|
|
fi
|
|
}
|
|
|
|
encode_b64() { # stdin: UTF-8 script -> stdout: UTF-16LE base64 (no BOM, no wraps)
|
|
iconv -f UTF-8 -t UTF-16LE | base64 -w0
|
|
}
|
|
|
|
size_gate() { # $1 = encoded string, $2 = force flag (0/1)
|
|
local n=${#1}
|
|
if [ "$n" -gt 6000 ] && [ "${2:-0}" != "1" ]; then
|
|
echo "[ERROR] encoded payload is ${n} chars (>6000); the agent fails on bodies this large." >&2
|
|
echo " Split the script into sections, or stage it as a file on the endpoint" >&2
|
|
echo " and dispatch 'powershell -File <path>'. Override with --force." >&2
|
|
return 1
|
|
elif [ "$n" -gt 4000 ]; then
|
|
echo "[WARNING] encoded payload is ${n} chars — near the agent's body-size failure zone (~7KB)." >&2
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
cmd_encode() {
|
|
local src="${1:-}"; [ -z "$src" ] && usage
|
|
local b64
|
|
b64="$(read_script "$src" | encode_b64)"
|
|
[ -z "$b64" ] && { echo "[ERROR] encoding produced nothing" >&2; _logerr "encode produced empty output" --context "src=$src"; exit 1; }
|
|
size_gate "$b64" 1 || true # encode mode: warn only, the paste target may cope
|
|
printf 'powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand %s\n' "$b64"
|
|
}
|
|
|
|
cmd_rmm() {
|
|
local agent="${1:-}"; shift || true
|
|
local src="${1:-}"; shift || true
|
|
[ -z "$agent" ] || [ -z "$src" ] && usage
|
|
local timeout=120 context="" wait=1 force=0
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--timeout) timeout="${2:?}"; shift 2 ;;
|
|
--user-session) context="user_session"; shift ;;
|
|
--no-wait) wait=0; shift ;;
|
|
--force) force=1; shift ;;
|
|
*) echo "[ERROR] unknown option: $1" >&2; usage ;;
|
|
esac
|
|
done
|
|
|
|
local b64
|
|
b64="$(read_script "$src" | encode_b64)"
|
|
[ -z "$b64" ] && { echo "[ERROR] encoding produced nothing" >&2; _logerr "encode produced empty output" --context "src=$src"; exit 1; }
|
|
size_gate "$b64" "$force" || exit 2
|
|
|
|
eval "$(bash "$ROOT/.claude/scripts/rmm-auth.sh")"
|
|
if [ -z "${TOKEN:-}" ] || [ -z "${RMM:-}" ]; then
|
|
echo "[ERROR] RMM auth failed (no TOKEN/RMM)" >&2
|
|
_logerr "RMM auth failed via rmm-auth.sh" --context "agent=$agent"
|
|
exit 3
|
|
fi
|
|
|
|
# command_type=shell (cmd.exe): the base64 blob is a single quote-free token,
|
|
# so no layer between here and powershell.exe can mangle it. timeout_seconds
|
|
# is the field the agent honors — 'timeout' is silently ignored (errorlog).
|
|
local oneliner payload resp cmd_id status
|
|
oneliner="powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${b64}"
|
|
payload="$(jq -nc --arg ct shell --arg cmd "$oneliner" --argjson to "$timeout" \
|
|
--arg cx "$context" \
|
|
'{command_type:$ct, command:$cmd, timeout_seconds:$to}
|
|
+ (if $cx != "" then {context:$cx} else {} end)')"
|
|
resp="$(printf '%s' "$payload" | curl -s -m 20 -X POST "$RMM/api/agents/$agent/command" \
|
|
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
|
--data-binary @-)"
|
|
cmd_id="$(printf '%s' "$resp" | jq -r '.command_id // empty' 2>/dev/null)"
|
|
status="$(printf '%s' "$resp" | jq -r '.status // empty' 2>/dev/null)"
|
|
if [ -z "$cmd_id" ]; then
|
|
echo "[ERROR] dispatch failed: $resp" >&2
|
|
_logerr "EncodedCommand dispatch failed" --context "agent=$agent resp=${resp:0:80}"
|
|
exit 3
|
|
fi
|
|
echo "[OK] dispatched command_id=$cmd_id (initial status: ${status:-unknown}, timeout_seconds=$timeout)"
|
|
[ "$wait" = "0" ] && exit 0
|
|
|
|
local max_polls=$(( (timeout + 30) / 5 )) count=0 result
|
|
while [ $count -lt $max_polls ]; do
|
|
result="$(curl -s -m 15 "$RMM/api/commands/$cmd_id" -H "Authorization: Bearer $TOKEN")"
|
|
status="$(printf '%s' "$result" | jq -r '.status // empty' 2>/dev/null)"
|
|
case "$status" in
|
|
completed|failed|cancelled|interrupted)
|
|
echo "--- status: $status ---"
|
|
printf '%s' "$result" | jq -r '
|
|
"exit_code: \(.exit_code // "n/a")",
|
|
"--- stdout ---", (.stdout // .output // ""),
|
|
"--- stderr ---", (.stderr // "")' 2>/dev/null || printf '%s\n' "$result"
|
|
[ "$status" = "completed" ] && exit 0 || exit 3 ;;
|
|
running|pending) count=$((count+1)); sleep 5 ;;
|
|
*) echo "[ERROR] empty/unknown status — response: $result" >&2
|
|
_logerr "poll returned empty status" --context "cmd=$cmd_id agent=$agent"
|
|
exit 3 ;;
|
|
esac
|
|
done
|
|
echo "[WARNING] poll timeout after $((max_polls*5))s — command $cmd_id may still be running (last: $status)"
|
|
exit 3
|
|
}
|
|
|
|
case "${1:-}" in
|
|
encode) shift; cmd_encode "$@" ;;
|
|
rmm) shift; cmd_rmm "$@" ;;
|
|
*) usage ;;
|
|
esac
|