Larvitz Blog

FreeBSD, Linux, all things cleanly engineered

Managing FreeBSD Jails with Ansible: The jailexec Connection Plugin



FreeBSD jails are elegant isolation containers, but managing them with Ansible has traditionally required either running SSH daemons inside each jail or using awkward workarounds. The jailexec connection plugin solves this by connecting to the jail host via SSH and using jexec to execute commands inside jails - just like you would manually.

jailexec in action

The Problem with Jail Management

When automating FreeBSD jail infrastructure, you face a choice:

  1. Run SSH in every jail - Works, but defeats much of the security benefit of jails and adds management overhead
  2. Use the jail connection plugin - Requires Ansible to run directly on the jail host, limiting remote management
  3. Manual jexec wrapper scripts - Brittle, hard to maintain, and doesn’t integrate cleanly with Ansible’s ecosystem

What I wanted was simple: SSH to my jail host, then use jexec to run commands inside any jail. That’s exactly what the jailexec plugin does.

How It Works

The plugin extends Ansible’s SSH connection to create a two-hop execution model:

[ Control Machine ] --SSH--> [ Jail Host ] --jexec--> [ Jail ]

When you run an Ansible task against a jail:

  1. The plugin establishes an SSH connection to the jail host
  2. Commands are wrapped with privilege escalation (doas or sudo) and jexec
  3. Output is captured and returned to Ansible as if running directly in the jail
  4. File transfers use a two-stage process: upload to the host, then move into the jail’s filesystem

This means your jails don’t need SSH or any additional services - just a working FreeBSD environment.

Installation

The plugin is a single Python file. Drop it into your Ansible plugins directory:

# User-specific installation
mkdir -p ~/.ansible/plugins/connection/
curl -o jailexec.py https://raw.githubusercontent.com/chofstede/ansible_jailexec/main/jailexec.py
mv jailexec.py ~/.ansible/plugins/connection/

# Or project-specific
mkdir -p connection_plugins/
cp jailexec.py connection_plugins/

For project-specific installation, add to your ansible.cfg:

[defaults]
connection_plugins = ./connection_plugins

Inventory Configuration

The key difference from standard Ansible inventory is specifying the jail host separately from the jail itself:

[freebsd_hosts]
jail-host.example.com ansible_connection=ssh ansible_user=ansible ansible_port=22

[freebsd_jails]
web-jail    ansible_connection=jailexec  ansible_jail_host=jail-host.example.com ansible_user=ansible
db-jail     ansible_connection=jailexec  ansible_jail_host=jail-host.example.com ansible_user=ansible
app-jail    ansible_connection=jailexec  ansible_jail_host=jail-host.example.com ansible_user=ansible

The inventory hostname becomes the jail name by default. SSH authentication settings are inherited from your SSH configuration or can be specified per-host.

Configuration Variables

Variable Default Description
ansible_jail_host (required) FreeBSD host running the jails
ansible_jail_name inventory_hostname Override jail name
ansible_jail_user root User for command execution in jail
ansible_jail_privilege_escalation doas Privilege escalation method
ansible_jail_remote_tmp /tmp/.ansible/tmp Temporary directory path

Jail Host Setup

The SSH user on your jail host needs privilege escalation configured for jail management commands. I recommend doas for its simplicity:

# Install doas
pkg install doas

# Configure /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

For sudo, the equivalent in /usr/local/etc/sudoers:

ansible ALL=(root) NOPASSWD: /usr/sbin/jls, /usr/sbin/jexec, /bin/mkdir, /bin/mv, /bin/rm

Basic Usage

Test connectivity:

ansible -i hosts.ini freebsd_jails -m ping

Expected output:

web-jail | SUCCESS => {
    "changed": false,
    "ping": "pong"
}

Run commands inside jails:

# Check FreeBSD version in all jails
ansible -i hosts.ini freebsd_jails -m command -a "freebsd-version"

# Install packages
ansible -i hosts.ini freebsd_jails -m community.general.pkgng -a "name=nginx state=present"

Example Playbook

A typical playbook for configuring a web server jail:

---
- name: Configure web server jail
  hosts: web-jail
  connection: jailexec
  become: true

  tasks:
    - name: Install nginx
      community.general.pkgng:
        name: nginx
        state: present

    - name: Copy nginx configuration
      ansible.builtin.copy:
        src: files/nginx.conf
        dest: /usr/local/etc/nginx/nginx.conf
        owner: root
        group: wheel
        mode: '0644'
      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

Multi-Environment Inventory

For more complex setups with multiple jail hosts:

[production_jails]
prod-web-01   ansible_connection=jailexec  ansible_jail_host=prod-host-01.example.com
prod-web-02   ansible_connection=jailexec  ansible_jail_host=prod-host-02.example.com
prod-db-01    ansible_connection=jailexec  ansible_jail_host=prod-host-01.example.com  ansible_jail_user=postgres

[staging_jails]
stage-web     ansible_connection=jailexec  ansible_jail_host=stage-host.example.com
stage-db      ansible_connection=jailexec  ansible_jail_host=stage-host.example.com

[all_jails:children]
production_jails
staging_jails

Security Design

The plugin implements several security measures:

Input validation: Jail names are validated against FreeBSD naming conventions. Path traversal attempts (..) and shell injection patterns are blocked.

Two-stage file transfers: Files are first uploaded to a temporary location the SSH user can access, then moved into the jail using privilege escalation. This prevents direct writes to privileged paths.

Secure temporary files: Temporary files use mode 600 and are cleaned up on failure.

Connection reuse: SSH connections are pooled, reducing authentication overhead and exposure.

Privilege escalation control: You choose between doas or sudo, and can lock down exactly which commands are permitted.

Troubleshooting

Enable verbose output for debugging:

# Maximum verbosity
ansible -vvv -i hosts.ini freebsd_jails -m ping

# With Ansible debugging
ANSIBLE_DEBUG=1 ansible -vvv -i hosts.ini freebsd_jails -m ping

Common Issues

No jail host specified” Add ansible_jail_host=your-host.example.com to your inventory.

Permission denied accessing jail” Verify doas/sudo configuration. Test manually on the jail host:

doas jls
doas jexec web-jail /bin/sh -c "id"

Jail ‘name’ not found” Check that the jail is running:

jls
service jail onestart web-jail

File transfer failures Verify the SSH user can write to /tmp on the jail host and that disk space is available.

Future Development

The plugin is actively maintained. Potential future additions:

  • Ansible Collection packaging for easier distribution
  • Support for jail templates and cloning operations
  • Parallel execution optimizations

Contributions are welcome on GitHub or Codeberg.

Conclusion

The jailexec plugin fills a gap in FreeBSD automation tooling. It brings proper Ansible support to jail management without compromising the security benefits of running minimal jail environments.

If you’re managing FreeBSD jails at any scale, this plugin lets you treat them as first-class Ansible targets - the same playbooks, roles, and patterns you use everywhere else, now working seamlessly with FreeBSD’s native containerization.


References