diff options
| author | Jan Tuomi <jan@jantuomi.fi> | 2026-05-13 00:13:57 +0300 |
|---|---|---|
| committer | Jan Tuomi <jan@jantuomi.fi> | 2026-05-16 18:42:27 +0300 |
| commit | b5860daf11ac353049cb1654b9414a129e5cfb96 (patch) | |
| tree | 87ed89711e4f0e85ace0a97fa123199152c67302 /connection_plugins | |
| parent | 4715a28fdcd87440400d17154bfa361d99db29cc (diff) | |
Rework
Diffstat (limited to 'connection_plugins')
| -rw-r--r-- | connection_plugins/__pycache__/jailexec.cpython-314.pyc | bin | 0 -> 15222 bytes | |||
| -rw-r--r-- | connection_plugins/jailexec.py | 322 |
2 files changed, 322 insertions, 0 deletions
diff --git a/connection_plugins/__pycache__/jailexec.cpython-314.pyc b/connection_plugins/__pycache__/jailexec.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..c1fe98b --- /dev/null +++ b/connection_plugins/__pycache__/jailexec.cpython-314.pyc diff --git a/connection_plugins/jailexec.py b/connection_plugins/jailexec.py new file mode 100644 index 0000000..4f4fab3 --- /dev/null +++ b/connection_plugins/jailexec.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025 Christian Hofstede-Kuhn <christian@hofstede.it> +# SPDX-License-Identifier: BSD-2-Clause + +"""FreeBSD jail connection plugin for Ansible. + +Opens an SSH session to a FreeBSD jail host (inheriting Ansible's built-in +ssh connection plugin) and wraps every command with ``jexec`` so Ansible +operates *inside* the target jail without needing direct SSH access to it. +""" + +from __future__ import annotations + +import os +import posixpath +import re +import shlex + +import yaml +from ansible.errors import AnsibleConnectionFailure, AnsibleError +from ansible.plugins.connection import ssh as _ssh_module +from ansible.plugins.connection.ssh import Connection as SSHConnection +from ansible.utils.display import Display + +display = Display() + +# Static stub so ``ansible-doc -t connection jailexec`` can read the plugin +# (ansible-doc parses the source file as AST and only understands literal +# strings). The full option set is built below and assigned over the top via +# ``globals()`` -- the AST walker only inspects ``ast.Assign`` nodes with a +# simple Name target, so a plain function-call *expression* statement is +# invisible to it. At runtime, the plugin loader reads the merged version. +DOCUMENTATION = """ + name: jailexec + short_description: Execute tasks in FreeBSD jails via jexec over SSH + description: + - Opens an SSH session to a FreeBSD jail host and wraps every command + with jexec so Ansible runs inside the target jail without needing + direct SSH into the jail. + - Inherits all options from the built-in ssh connection plugin. + author: Christian Hofstede-Kuhn <christian@hofstede.it> + version_added: "1.1.0" + options: + jail_name: + description: Jail name. Defaults to the inventory hostname. + type: str + vars: + - name: ansible_jail_name + jail_host: + description: Hostname or IP of the FreeBSD host that runs the jail. + type: str + required: true + vars: + - name: ansible_jail_host + jail_root: + description: + - Absolute on-host filesystem path of the jail, used as the + base for put_file and fetch_file. + - If unset, the plugin probes the host with + ``jls -j <name> path`` on the first file transfer. + - Set this for nested or VNET jail setups where the probe + does not return the expected path. + type: str + version_added: "1.2.0" + vars: + - name: ansible_jail_root + jail_user: + description: User to run commands as inside the jail. + type: str + default: root + vars: + - name: ansible_jail_user + privilege_escalation: + description: Command used on the jail host to run jexec as root. + type: str + default: doas + choices: [doas, sudo, none] + vars: + - name: ansible_jail_privilege_escalation +""" + + +def _extend_with_ssh_options(doc): + """Merge SSH plugin options into our DOCUMENTATION at import time. + + Pulling options from the live SSH plugin (rather than freezing a copy) + keeps us in sync with whichever ansible-core version is installed; newer + ansible-core releases have added options (e.g. ``password_mechanism``) + that older snapshots didn't know about, and a frozen list would cause + ``get_option`` to return None and trigger type errors downstream. + """ + ssh_doc = yaml.safe_load(_ssh_module.DOCUMENTATION) or {} + our_doc = yaml.safe_load(doc) or {} + merged = dict(ssh_doc.get("options") or {}) + merged.update(our_doc.get("options") or {}) + our_doc["options"] = merged + return yaml.safe_dump(our_doc, sort_keys=False) + + +globals().update(DOCUMENTATION=_extend_with_ssh_options(DOCUMENTATION)) + + +MAX_JAIL_NAME_LENGTH = 255 +JAIL_NAME_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9._-]*$") +PRIVESC_CHOICES = ("doas", "sudo", "none") +# /tmp is on the remote jail host, not the Ansible controller. File names are +# randomized via ``os.urandom`` in ``put_file``, which defeats predictable-name +# attacks. Bandit's B108 check is about local-tmp usage and does not apply. +STAGING_DIR = "/tmp" # nosec B108 +STAGING_PREFIX = "ansible-jailexec-" + + +def validate_jail_name(name): + """Reject empty, overlong, or shell-unsafe jail names.""" + if not name or not str(name).strip(): + raise AnsibleConnectionFailure("Jail name cannot be empty") + name = str(name).strip() + if len(name) > MAX_JAIL_NAME_LENGTH: + raise AnsibleConnectionFailure( + f"Jail name too long (max {MAX_JAIL_NAME_LENGTH}): {name!r}" + ) + if not JAIL_NAME_RE.match(name): + raise AnsibleConnectionFailure( + f"Invalid jail name {name!r}: must start with a letter, digit or " + "underscore and contain only letters, digits, dots, underscores " + "or hyphens." + ) + return name + + +def ensure_no_traversal(path): + """Reject paths containing a ``..`` component (path traversal).""" + if path and ".." in path.replace("\\", "/").split("/"): + raise AnsibleError(f"Path contains '..' traversal: {path}") + + +def validate_jail_root(path): + """Normalize and validate a user-provided jail-root override. + + Must be a non-empty absolute POSIX path without any ``..`` components. + """ + path = (path or "").strip() + if not path: + raise AnsibleConnectionFailure("ansible_jail_root cannot be empty") + if not path.startswith("/"): + raise AnsibleConnectionFailure( + f"ansible_jail_root must be an absolute path, got {path!r}" + ) + ensure_no_traversal(path) + return posixpath.normpath(path) + + +def _decode(data): + """Return ``data`` as a str. Bytes are decoded leniently; None becomes ''.""" + if data is None: + return "" + if isinstance(data, bytes): + return data.decode("utf-8", "replace") + return data + + +def _shelljoin(*argv): + """Shell-join a command + args safely for transport over SSH.""" + return " ".join(shlex.quote(str(a)) for a in argv) + + +class Connection(SSHConnection): + """SSH to a jail host, run commands inside the jail via jexec.""" + + transport = "jailexec" + has_pipelining = True + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._jail_root = None + + # ---- options --------------------------------------------------------- + + @property + def jail_name(self): + name = self.get_option("jail_name") or self._play_context.remote_addr + return validate_jail_name(name) + + @property + def jail_user(self): + # Normalize None / blank / whitespace-only to "root". + return (self.get_option("jail_user") or "").strip() or "root" + + @property + def privesc(self): + # ansible-core >= 2.20 rejects off-``choices`` values at ``set_option`` + # time; older releases defer the check, so validate here too. + value = self.get_option("privilege_escalation") + if value not in PRIVESC_CHOICES: + raise AnsibleConnectionFailure( + f"Invalid privilege_escalation {value!r}: " + f"must be one of {', '.join(PRIVESC_CHOICES)}" + ) + if value == "none": + return "" + return value + + # ---- connect / lifecycle -------------------------------------------- + + def _connect(self): + if self._connected: + return self + + jail_host = (self.get_option("jail_host") or "").strip() + if not jail_host: + raise AnsibleConnectionFailure( + f"ansible_jail_host is not set for jail {self.jail_name!r}" + ) + # Redirect the inherited SSH plugin at the jail *host* instead of the + # jail (inventory) name. This is the one hook we need -- everything + # else comes from the SSH base class. + self.set_option("host", jail_host) + super()._connect() + # SSH's _connect is a no-op on _connected, but ConnectionBase's + # exec_command/put_file/fetch_file are wrapped with @ensure_connect, + # which re-enters self._connect() whenever _connected is False. We + # flip it here so the jail-root probe issued on first file op (via + # super().exec_command) doesn't recurse into us. + self._connected = True + return self + + def close(self): + self._jail_root = None + super().close() + + # ---- jail metadata --------------------------------------------------- + + def _resolve_jail_root(self): + """Look up and cache the on-host filesystem path of the jail. + + If ``ansible_jail_root`` is set, that value is used verbatim and no + SSH probe happens. Otherwise the path is resolved via + ``jls -j <name> path`` on the first file operation, then cached. + """ + if self._jail_root: + return self._jail_root + + override = self.get_option("jail_root") + if override: + self._jail_root = validate_jail_root(override) + display.vvv( + f"jailexec: jail {self.jail_name!r} root is {self._jail_root} " + "(from ansible_jail_root)", + host=self.jail_name, + ) + return self._jail_root + + name = self.jail_name + rc, stdout, stderr = super().exec_command( + _shelljoin(*(([self.privesc] if self.privesc else []) + ["jls", "-j", name, "path"])) + ) + if rc != 0: + msg = _decode(stderr).strip() or "jail not found or inaccessible" + raise AnsibleConnectionFailure(f"Cannot access jail {name!r}: {msg}") + lines = _decode(stdout).strip().splitlines() + root = lines[0].strip() if lines else "" + if not root: + raise AnsibleConnectionFailure( + f"Jail {name!r} returned no filesystem root (is it running?)" + ) + self._jail_root = root + display.vvv(f"jailexec: jail {name!r} root is {root}", host=name) + return root + + def _jail_path(self, path): + """Map a path inside the jail to its absolute path on the host.""" + ensure_no_traversal(path) + root = self._resolve_jail_root() + return posixpath.normpath(posixpath.join(root, path.lstrip("/"))) + + # ---- exec / transfer ------------------------------------------------- + + def exec_command(self, cmd, in_data=None, sudoable=True): + if not cmd or not str(cmd).strip(): + raise AnsibleError("Command cannot be empty") + + argv = [self.privesc, "jexec"] if self.privesc else ["jexec"] + if self.jail_user != "root": + argv += ["-u", self.jail_user] + argv += [self.jail_name, "/bin/sh", "-c", cmd] + wrapped = _shelljoin(*argv) + + display.vvv(f"jailexec: exec [{self.jail_name}]: {cmd}", host=self.jail_name) + return super().exec_command(wrapped, in_data=in_data, sudoable=sudoable) + + def put_file(self, in_path, out_path): + dest = self._jail_path(out_path) + dest_dir = posixpath.dirname(dest) + staged = posixpath.join(STAGING_DIR, f"{STAGING_PREFIX}{os.urandom(12).hex()}") + + display.vvv( + f"jailexec: put_file {in_path} -> jail:{out_path}", host=self.jail_name + ) + super().put_file(in_path, staged) + # Single round-trip: mkdir + move. Both go through privilege + # escalation because the destination lives inside the jail root, + # which is typically only writable by root on the host. + pe = shlex.quote(self.privesc) + " " if self.privesc else "" + move = ( + f"{pe}mkdir -p {shlex.quote(dest_dir)} && " + f"{pe}mv {shlex.quote(staged)} {shlex.quote(dest)}" + ) + rc, _, stderr = super().exec_command(move) + if rc != 0: + # Best-effort cleanup of the orphan staged file; ignore failures. + super().exec_command(f"rm -f {shlex.quote(staged)}") + raise AnsibleError( + f"put_file to jail:{out_path} failed: " + f"{_decode(stderr).strip() or 'unknown error'}" + ) + + def fetch_file(self, in_path, out_path): + src = self._jail_path(in_path) + display.vvv( + f"jailexec: fetch_file jail:{in_path} -> {out_path}", host=self.jail_name + ) + super().fetch_file(src, out_path) |
