aboutsummaryrefslogtreecommitdiffstats
path: root/connection_plugins/jailexec.py
blob: 4f4fab341b63704c4f54d8fd32eaed1dbc57c095 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
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)