Ansible connection plugin for FreeBSD jails via jexec over SSH
Project description
Ansible FreeBSD Jail Connection Plugin
An Ansible connection plugin that runs tasks inside a FreeBSD jail by SSH-ing to the jail host and wrapping every command in jexec. You do not need direct SSH access to the jail itself — the jail needs no sshd, no Python-over-SSH setup, no network address of its own.
The plugin inherits from Ansible's built-in ssh connection plugin, so every SSH option (control persist, jump hosts, key files, custom ports, etc.) works unchanged.
Features
- Inherits the full SSH plugin: options are merged from the live
sshplugin at import time, so the plugin stays in sync with whicheveransible-coreis installed. - Everything happens inside the jail: commands and file transfers run through
jexec, confined to the jail's chroot. A symlink planted inside a jail can never redirect a privileged write or read onto a host path. - Safe by construction: jail names are validated, paths are traversal-checked and normalized, and every shell argument is
shlex.quoted. - Minimal host footprint: the SSH user needs a
doas/sudorule for exactly one command —jexec. - Zero extra round trips for exec-only workloads: no probing at connect time;
put_filecosts a single extra exec. doas,sudo, ornonefor host-side privilege escalation aroundjexec.
Demo
Executing Ansible tasks inside FreeBSD jails through the
jailexec connection plugin.
How it works
For every task, the plugin opens a normal SSH session to the jail host (reusing ControlPersist connections like the stock ssh plugin) and runs:
doas jexec [-U <jail_user>] <jail_name> /bin/sh -c '<command>'
File transfers stage through the host and complete inside the jail:
- put_file: upload to a random
/tmp/ansible-jailexec-<hex>name on the host via SFTP, thendoas jexec <jail> /bin/sh -c 'mkdir -p <dir> && cat > <dest>' < <staged>— the write resolves entirely within the jail's filesystem namespace. - fetch_file:
doas jexec <jail> /bin/sh -c 'cat < <src>' > <staged>(staged copy created withumask 077), then download via SFTP and remove the staged copy.
Requirements
- Control machine: Python 3.9+,
ansible-core >= 2.14 - Jail host: FreeBSD with
jexecavailable, anddoasorsudoconfigured for the SSH user (not needed if you SSH in as root — seeansible_jail_privilege_escalation=none) - Jails: must be running, with
/bin/shandcatavailable (both ship in the FreeBSD base system), plus Python for Ansible modules as usual
Installation
As a user plugin
curl -O https://raw.githubusercontent.com/chofstede/ansible_jailexec/main/jailexec.py
mkdir -p ~/.ansible/plugins/connection/
mv jailexec.py ~/.ansible/plugins/connection/
As a project plugin
mkdir -p connection_plugins/
curl -o connection_plugins/jailexec.py \
https://raw.githubusercontent.com/chofstede/ansible_jailexec/main/jailexec.py
Then point Ansible at it from ansible.cfg:
[defaults]
connection_plugins = ./connection_plugins
Via pip
pip install ansible-jailexec
(Installs jailexec.py as a top-level module; Ansible's plugin loader will still need it under a connection_plugins/ path, or set ANSIBLE_CONNECTION_PLUGINS to the install location.)
Quick start
1. Inventory
[freebsd_jails]
web-jail ansible_connection=jailexec ansible_jail_host=jail-host.example.com
db-jail ansible_connection=jailexec ansible_jail_host=jail-host.example.com ansible_jail_user=postgres
app-jail ansible_connection=jailexec ansible_jail_host=jail-host.example.com ansible_ssh_port=30822
The inventory hostname (web-jail, db-jail, …) doubles as the jail name unless you override it with ansible_jail_name. Setting ansible_host does not affect the jail name — it is an SSH-level alias for the jail host's address, like ansible_jail_host.
2. Ping
ansible -i hosts.ini freebsd_jails -m ping
Expected:
web-jail | SUCCESS => {
"changed": false,
"ping": "pong"
}
3. Run tasks
ansible -i hosts.ini freebsd_jails -m ansible.builtin.command -a "uname -a"
ansible -i hosts.ini freebsd_jails -m community.general.pkgng -a "name=nginx state=present"
Configuration reference
Plugin-specific options
| Variable | Required | Default | Description |
|---|---|---|---|
ansible_jail_host |
✅ | — | Hostname or IP of the FreeBSD host that runs the jail. |
ansible_jail_name |
inventory hostname | Override the jail name if it differs from the inventory hostname. | |
ansible_jail_user |
root |
User to run commands (and file transfers) as inside the jail, resolved against the jail's password database (jexec -U). |
|
ansible_jail_privilege_escalation |
doas |
Host-side privilege escalation for jexec. One of doas, sudo, none. Use none when you already SSH to the host as root and have no doas/sudo. |
|
ansible_jail_root |
— | Deprecated, ignored since 2.0.0. Transfers run inside the jail via jexec, so the jail's on-host path is no longer needed. Setting it only produces a warning. |
SSH options
The plugin inherits every option of the built-in ssh connection plugin — ansible_ssh_port, ansible_ssh_private_key_file, ansible_ssh_common_args, ansible_ssh_extra_args, ControlPersist, jump hosts, and so on.
For the full list, see:
ansible-doc -t connection ssh
Privilege escalation: two independent layers
There are two places where privileges can be escalated, and it's easy to conflate them:
ansible_jail_privilege_escalation(this plugin) — runsjexecon the host as root so the plugin can enter the jail. Default:doas. Set it tononewhen the SSH user is already root on the host (so nodoas/sudois installed); the plugin then invokesjexecdirectly.- Ansible
become(become: yes,--become,ansible_become_method) — runs the task payload inside the jail under a different user. Use this ifansible_jail_useris non-root and the task needs root inside the jail.
Typical setup: leave ansible_jail_user=root (the default) and skip become entirely; the plugin's own privilege escalation is already enough.
Note that with a non-root ansible_jail_user, file transfers also run as that user inside the jail — copying to root-owned locations then requires become, exactly as it would over plain SSH.
FreeBSD host setup
The SSH user needs to run exactly one command as root: jexec.
Add it to doas:
# /usr/local/etc/doas.conf
permit nopass ansible as root cmd jexec
or to sudoers (edit with visudo):
ansible ALL=(root) NOPASSWD: /usr/sbin/jexec
Upgrading from 1.x? The
jls,mkdir,mv, andrmrules that earlier versions needed are no longer used and can be removed.
Playbook example
---
- name: Configure FreeBSD jails
hosts: freebsd_jails
gather_facts: true
tasks:
- name: Install nginx
community.general.pkgng:
name: nginx
state: present
- name: Ship configuration
ansible.builtin.copy:
src: nginx.conf
dest: /usr/local/etc/nginx/nginx.conf
backup: true
notify: restart nginx
- name: Enable and start nginx
ansible.builtin.service:
name: nginx
state: started
enabled: true
handlers:
- name: restart nginx
ansible.builtin.service:
name: nginx
state: restarted
Upgrading from 1.x
Version 2.0.0 moves all file transfers inside the jail (see Security considerations and the CHANGELOG). For typical setups (ansible_jail_user=root) no inventory change is needed. Things to check:
- doas/sudoers can be trimmed to the single
jexecrule shown above. ansible_jail_rootis ignored (with a warning) — simply remove it; the path-mapping problem it worked around no longer exists.ansible_jail_useris now resolved in the jail's password database (jexec -U), as the documentation always promised. If you depended on the host's password database (jexec -usemantics), align the user accounts or usebecome.- Transfers run as
ansible_jail_user, no longer silently as root. Non-root jail users writing to root-owned paths needbecome: yes.
Troubleshooting
Enable verbose mode:
ansible -vvv -i hosts.ini freebsd_jails -m ping
Plugin log lines are prefixed with jailexec::
jailexec: exec [web-jail]: /bin/sh -c 'echo hi'
jailexec: put_file /local/nginx.conf -> jail:/usr/local/etc/nginx/nginx.conf
jailexec: fetch_file jail:/var/log/nginx/access.log -> /tmp/access.log
Common error messages
| Message | Cause | Fix |
|---|---|---|
ansible_jail_host is not set for jail 'X' |
Missing inventory variable. | Add ansible_jail_host=<host> to inventory. |
jexec: jail "X" not found (in task stderr) |
The jail isn't running, or the name is wrong. | jls on the host; service jail onestart X; check ansible_jail_name. |
doas: not found / sudo: not found (exit 127) |
No privilege-escalation helper on the host. | Install/configure doas, or set ansible_jail_privilege_escalation=none when SSH-ing in as root. |
doas: Operation not permitted / sudo: a password is required |
The SSH user has no (passwordless) rule for jexec. |
Add the doas.conf/sudoers rule shown above. |
Invalid jail name 'X': … |
Jail name contains shell-unsafe characters or starts with -/.. |
Rename, or use ansible_jail_name to override. |
Path contains '..' traversal: X |
A module tried to put_file/fetch_file with .. in the path. |
Use absolute paths without .. segments. |
put_file to jail:X failed: … |
Writing inside the jail failed (permissions, read-only filesystem, full disk). | Check the path and ansible_jail_user's rights inside the jail; use become for root-owned paths. |
fetch_file from jail:X failed: … |
Reading inside the jail failed (missing file, permissions). | Verify the path exists and is readable by ansible_jail_user. |
ansible_jail_root is deprecated and ignored (warning) |
Leftover 1.x inventory variable. | Remove ansible_jail_root; it is no longer needed. |
Security considerations
- Transfers are confined to the jail:
put_fileandfetch_fileexecutemkdir/catinside the jail viajexec, so all path resolution happens within the jail's chroot. A symlink planted inside a (potentially compromised) jail cannot redirect a privileged write or read to a host path. (Versions before 2.0.0 performed root-ownedmvoperations on host-side paths and were vulnerable to exactly that — upgrade.) - Input validation: jail names are matched against
^[A-Za-z0-9_][A-Za-z0-9._-]*$and length-capped at 255. Paths are rejected if any component is.., and are normalized to absolute in-jail paths. - Shell safety: every argument crossing the SSH wire is
shlex.quoted; the user-supplied command is the final argument to/bin/sh -cand is not further interpreted by the plugin. Fetched files are read withcat < fileredirection, ruling out option injection. - File staging: transfers stage in
/tmpon the host under a random name (ansible-jailexec-<hex>, 96 bits of randomness). Fetched files are staged withumask 077so they are never world-readable; staged files are removed even when the transfer fails. - Minimal escalation surface: the only command the SSH user runs via
doas/sudoisjexec. - No new network ports: everything rides the existing SSH connection, including control-persist reuse.
- Internal plumbing never engages
become: plugin-internal commands run withsudoable=False, so they neither wait for become prompts nor allocate pseudo-terminals.
Development
# Install test dependencies
pip install -r requirements-test.txt
# Run the test suite with coverage (gate: 100%)
pytest
# Format / lint / security scan (same as CI)
black jailexec.py tests/
isort jailexec.py tests/
flake8 jailexec.py --max-line-length=100 --extend-ignore=E203,W503
bandit -r jailexec.py
See tests/integration/README.md for end-to-end tests against a real FreeBSD host.
License
BSD 2-Clause — see LICENSE.
Support
- Issues: https://github.com/chofstede/ansible_jailexec/issues
- Changelog: CHANGELOG.md
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file ansible_jailexec-2.0.0.tar.gz.
File metadata
- Download URL: ansible_jailexec-2.0.0.tar.gz
- Upload date:
- Size: 24.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6b61720b8cc64d6a925f6b49f6e4da20d25bcdde413fdb522c92c642097c688b
|
|
| MD5 |
c98877d53e994c1ff400fbc9bd8fde79
|
|
| BLAKE2b-256 |
c6f2a27147761228b25d22e8785da9c269fd7ff301ece7ba9e0d2630611d65c7
|
File details
Details for the file ansible_jailexec-2.0.0-py3-none-any.whl.
File metadata
- Download URL: ansible_jailexec-2.0.0-py3-none-any.whl
- Upload date:
- Size: 12.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6ae4654391ef9cc415fe75e7a1ec70545af4487e18a985e80ddc4fd57480c1ea
|
|
| MD5 |
b68e54b9deb65121ef435ddcdc38672a
|
|
| BLAKE2b-256 |
de85c4cec001777f7d35d4a9e174c79e482094644b2a16361241154bfa01c50e
|