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 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. - Safe by construction: jail names are validated, paths are traversal-checked, and every shell argument is
shlex.quoted. - Lazy jail-root probe: the on-host path of the jail is resolved only on the first file transfer, so exec-only workloads pay zero extra round trips.
- Single round-trip
put_file: staged file is moved into the jail with one combinedmkdir -p && mvcommand. doasorsudofor host-side privilege escalation aroundjls/jexec/mkdir/mv/rm.
Demo
Executing Ansible tasks inside FreeBSD jails through the
jailexec connection plugin.
Requirements
- Control machine: Python 3.9+,
ansible-core >= 2.14 - Jail host: FreeBSD with
jlsandjexecavailable, anddoasorsudoconfigured for the SSH user - Jails: must be running (so
jls -j <name> pathreturns their filesystem root)
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.
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 as inside the jail. | |
ansible_jail_root |
auto-detected via jls -j <name> path |
Absolute on-host path of the jail. Set this for nested or VNET jail setups where the probe returns an unexpected path. | |
ansible_jail_privilege_escalation |
doas |
Host-side privilege escalation for jls/jexec. One of doas, sudo. |
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) — runsjls/jexec/mkdir/mv/rmon the host as root so the plugin can enter the jail and write into its filesystem. Default:doas.- 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.
FreeBSD host setup
Add the SSH user to doas:
# /usr/local/etc/doas.conf
permit nopass ansible as root cmd jls
permit nopass ansible as root cmd jexec
permit nopass ansible as root cmd mkdir
permit nopass ansible as root cmd mv
permit nopass ansible as root cmd rm
or to sudoers (edit with visudo):
ansible ALL=(root) NOPASSWD: /usr/sbin/jls, /usr/sbin/jexec, /bin/mkdir, /bin/mv, /bin/rm
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
Troubleshooting
Enable verbose mode:
ansible -vvv -i hosts.ini freebsd_jails -m ping
Plugin log lines are prefixed with jailexec::
jailexec: jail 'web-jail' root is /jail/web-jail
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. |
Cannot access jail 'X': … |
jls -j X path failed on the host. Typically the jail isn't running or doas/sudo rejected jls. |
doas jls on the host; check service jail status. |
Jail 'X' returned no filesystem root (is it running?) |
jls succeeded but returned blank. Jail defined but not started. |
service jail onestart X. |
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: … |
mkdir/mv into the jail root failed (permissions, full disk). |
Check host-side doas/sudo rules and free space. |
Security considerations
- 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... - 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. - File transfers: files are staged in
/tmpon the host with a random name (ansible-jailexec-<hex>), then moved into the jail using the configured privilege-escalation helper. On move failure, the staged file is best-effort removed. - No new network ports: everything rides the existing SSH connection, including control-persist reuse.
Development
# Install test dependencies
pip install -r requirements-test.txt
# Run the test suite with coverage
pytest
# Syntax check
python3 -m py_compile jailexec.py
See tests/integration/README.md for end-to-end tests against a real FreeBSD host.
License
BSD 2-Clause — see LICENSE.
Support
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-1.2.0.tar.gz.
File metadata
- Download URL: ansible_jailexec-1.2.0.tar.gz
- Upload date:
- Size: 19.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
762687384febd4bea6faacb92c0d8693bb3e63e88029404bc9e1aa970e8e914b
|
|
| MD5 |
a1b370a834700c366ba7b627b5d99368
|
|
| BLAKE2b-256 |
0dd86b0ca40b346571ab6d82346aa715f1df07ab512385cffb203891c91cf98f
|
File details
Details for the file ansible_jailexec-1.2.0-py3-none-any.whl.
File metadata
- Download URL: ansible_jailexec-1.2.0-py3-none-any.whl
- Upload date:
- Size: 10.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1a2832cb1f1e3bb91ebf76ca38259fb32ef32c85ec38011f4856f4da02c0f25d
|
|
| MD5 |
804f2f2d6707c0b7d6ea242afa644889
|
|
| BLAKE2b-256 |
4abf3049f59b8738c7c2b152d1828a7f2b7b29065978588edcbf5080918612eb
|