module

Krikri::BatchScript

Wire protocol for task batching (on by default; --no-batching disables it): builds the bash script that runs several plugin invocations in one SSH round trip, and parses the results back out.

Both directions use base64 framing, never marker strings mixed into a plugin's own stdout/stderr - a shell/command task's captured output could in principle contain anything, including a string that collides with a hand-picked delimiter. Base64's own alphabet structurally can't produce that collision, so this is immune to it rather than merely unlikely to hit it.

The generated script is meant to be piped via a single ssh ... bash -s invocation's stdin. Each step's config is embedded in the script itself (also base64) rather than streamed separately over that same stdin - interleaving "script source" and "step data" on one stdin stream is a well-known footgun (bash's own script-reading can read ahead in blocks, not strictly line-by-line, corrupting the boundary between where the script ends and data begins). A single self-contained script sidesteps that entirely.

Assumes GNU coreutils base64 (supports -w0, no line wrapping) on the target - consistent with this project's existing assumption of a Debian/RHEL-family Linux target elsewhere (dpkg/rpm detection, etc.).

Constants

REMOTE_DIR_BASE = "/var/tmp/.krikri-playbook"

/var/tmp, not /tmp - see PluginManager.remote_plugin_dir for why (some hardening roles remount /tmp as a fresh, empty tmpfs mid-play, which would silently wipe this batch's own per-step output files out from under it).

TOP_LEVEL_FAILED_AWK = "{\n line = $0\n depth = 0\n in_str = 0\n prev = \"\"\n failed = 0\n bs_run = 0\n n = length(line)\n q = sprintf(\"%c\", 34)\n bs = sprintf(\"%c\", 92)\n key = q \"failed\" q \":\"\n for (i = 1; i <= n; i++) {\n c = substr(line, i, 1)\n if (in_str) {\n if (c == bs) {\n bs_run++\n } else {\n # a quote closes the string only when the run of\n # immediately-preceding backslashes is even (an odd run\n # escapes it: \"x\" ends with an escaped backslash, so the\n # quote is the real terminator)\n if (c == q && bs_run % 2 == 0) in_str = 0\n bs_run = 0\n }\n } else if (depth == 1 && substr(line, i, 9) == key && substr(line, i + 9) ~ /^ *true/) {\n failed = 1\n } else if (c == q) {\n in_str = 1\n } else if (c == \"{\" || c == \"[\") {\n depth++\n } else if (c == \"}\" || c == \"]\") {\n depth--\n }\n prev = c\n }\n exit (failed ? 0 : 1)\n}"

awk program (POSIX awk, run against one step's stdout file) that answers "does this result JSON have a TOP-LEVEL "failed": true?"

  • exits 0 when it does. Tracks JSON string/escape state and brace depth so "failed" keys nested inside result values (uri:'s parsed response body, string-valued fields carrying escaped copies) never trip it. The result is always a single-line compact JSON object, so per-line state resets are harmless.

Class methods

build(batch_id : String, steps : Array(Step), remote_user : String | Nil = nil) : String

Builds the full script for one batch. batch_id should be unique per invocation (avoids any risk of colliding with a leftover directory from an earlier batch against the same host). remote_user is the connecting SSH user - it names the per-user parent directory (see #remote_dir_prefix).

Source
parse(raw_stdout : String) : Hash(Int32, StepResult)

Parses a batch script's stdout (as captured from the single SSH invocation that ran it) back into per-step results, keyed by the step's original index. A step whose index is missing never ran (the script halted before reaching it, or the whole batch never started)

  • callers must not treat a missing entry as an error on its own.
Source
remote_dir_prefix(remote_user : String | Nil) : String

Per-connecting-user parent for batch working dirs, sharing the plugin staging dir's <user>-<hash> tag (PluginManager.staging_dir_tag): /var/tmp is sticky-world, so the old single shared parent was an entirely predictable path any local user could pre-create before krikri's first run (the CVE-2014-3498 class), and two krikri users on one host fought over it. Per-user, that cross-user race disappears; the generated script's owner/symlink checks stay as defense in depth. Lives under the plugin staging base (not beside it) so this script's 0700 chmod can never clobber the staging base's deliberately traversable 0711 (a become_user: exec reaches binaries through it).

Source
stamp_missing_on_nonzero_exit(parsed : Hash(Int32, StepResult), exit_code : Int32, stderr : String, step_count : Int32) : Hash(Int32, StepResult)

Stamps the steps a batch script that exited NONZERO never produced results for, once the transport-death stamping above has had its turn - the script's own pre-step aborts are the case it exists for, the staging-safety refusals (exit 75: a batch directory that is a file, a symlink or foreign-owned, or one a full disk simply failed to create) above all. The script refuses BEFORE any step runs, so #parse recovers nothing; unlabeled, every member of the group then falls through execute_batch_group's "no entry = skipped" contract. Atlantic round 29896654 (0xRedpoll.ludus_mythic_teamserver, issue #834) turned exactly that into the worst possible report: seventeen consecutive tasks printed skipping: and the run exited 0 while real Ansible - one ssh round trip per task - fails the task in flight (its own temporary-directory creation fails the same way on a full disk) and halts the host. The stamped steps carry the script's own evidence; interpret_remote_result books them as failed tasks (the refusal stderr names krikri's batch dir, never the ssh transport), the first member's failure halts the host, and the members after it stay silent exactly like real's post-failure tasks.

A completed script always exits 0 (a failing step dumps and exits 0), so any nonzero exit leaves at least one step without a result of its own making - including a mid-script death ssh reports with a non-255 status (the transport-death stamp only answers 255). Steps whose results DID arrive (streamed before the death, or a fail-fast dump - though a fail-fast always exits 0) are left untouched.

Public (spec seam) like stamp_missing_on_transport_death, for the same reason.

Source
stamp_missing_on_transport_death(parsed : Hash(Int32, StepResult), exit_code : Int32, stderr : String, step_count : Int32) : Hash(Int32, StepResult)

Stamps the steps whose results never arrived when the SSH transport died mid-script (ssh's own 255 exit), leaving the steps that DID complete - their streamed results are in parsed via #parse - untouched. Real Ansible runs every task as its own ssh round trip, so a mid-play sshd death books the already-completed tasks as ok and only the task in flight as UNREACHABLE; without this stamping the missing steps would have no entry at all, which execute_batch_group's contract reads as "skipped", silently NOT running tasks the playbook asked for.

The 255 gate is what makes this safe: a batch script that COMPLETED always exits 0 (a failing step dumps and exits 0; the staging- safety refusals exit 75), so 255 can only come from ssh itself. Public (spec seam) because run_batch_script applies it to live transport output and the partial-recovery contract is worth pinning down directly.

Source

Nested types