I use Ansible to deploy FreeBSD jails to my servers. I was running into an issue where the jails were starting without network connectivity. This led me down a path of researching the best way to accomplish my isolated network configuration for untrusted jails.

The Setup

I use a single 10G network interface on my main server. This provides connectivity to the baremetal host and also trunks about 30 VLANs to provide connectivity to various jails.

Originally I defined all VLANs in the /etc/rc.conf.local file under the cloned_interfaces= setting. This worked, but as I integrated more of the jail provisioning process into Ansible, the modifications became a bit more cumbersome.

Now, I take advantage of the /etc/rc.conf.d/netif directory and make minimal changes to the /etc/rc.conf.local file.

Ansible

Using some community modules and pre-deployment prompts, I can now reliably deploy jails with VNET interfaces bridged to VLANs. I'll break down the playbook below and explain each module.

- name: VNet settings
  when: hostvars['localhost'].vnet | default(false) | bool
  block:
    - name: Add vlan to rc.conf.local interface list
      community.general.sysrc:
        name: "vlans_{{ hostvars['localhost'].interface }}"
        state: value_present
        value: "{{ hostvars['localhost'].vlan }}"
        path: /etc/rc.conf.local

    - name: Create persistent vlan entry
      ansible.builtin.template:
        src: templates/netif.j2
        dest: "/etc/rc.conf.d/netif/vlan{{ hostvars['localhost'].vlan }}.conf"
        mode: "0640"

    - name: Bring up vlan interface
      ansible.builtin.command:
        cmd: "ifconfig vlan{{ hostvars['localhost'].vlan }} create vlan {{ hostvars['localhost'].vlan }}
          vlandev {{ hostvars['localhost'].interface }} name vlan{{ hostvars['localhost'].vlan }} up"
      when: "'vlan' + hostvars['localhost'].vlan not in ansible_facts.interfaces"

    - name: Pause for 4 seconds to wait for vlan interface to come up with proper MTU.
      ansible.builtin.pause:
        seconds: 4

- name: Start jail
  ansible.builtin.command:
    cmd: "jail -c {{ hostvars['localhost'].jail }}"
  register: result
  until: result.rc == 0
  retries: 10
  delay: 10
  changed_when: "'Starting sshd' in result.stdout"

- name: Setup DNS
  ansible.builtin.template:
    src: resolv.conf.j2
    dest: "{{ jpath }}/etc/resolv.conf"
    mode: "0644"

- name: Install required packages
  ansible.builtin.command:
    cmd: "jexec {{ hostvars['localhost'].jail }} env ASSUME_ALWAYS_YES=yes pkg install -y sudo python security/pam_ssh_agent_auth ca_root_nss"
  register: packages
  changed_when: packages.rc == "0"
  until: packages.rc == 0
  retries: 100
  delay: 10

Block

I start with a block module. Since most of my servers are VPS' hosted with a single IP address, they don't use VLANs or VNETs. The block module includes a when conditional that only runs if the fact vnet is true.

sysrc

Using the sysrc module, I ensure the VLAN number is included in the list of VLANs for the server's interface. The sysrc module performs an append where it will only add the value if it does not exist in the current value. This allows me to deploy multiple jails in a single VLAN but only add a single VLAN ID to he vlans_<intf>= value in /etc/rc.conf.local.
The interface and vlan facts are set during the pre-deployment process. Depending on the server selected, Ansible gathers facts using an include_vars for the destination host.

template (vlan config)

The template module builds the VLAN rc file in the /etc/rc.conf.d/netif/ directory. Ansible is idempotent so if this file already exists (meaning the VLAN already exists), it will simple mark this task as "OK" and move on.

command (bring up vlan)

This module brings up the VLAN interface, but like the previous task, Ansible is aware of the system's state. Using the host's interface facts, Ansible will only perform this task if the VLAN is missing from the server's interface list.

pause

Pausing the playbook here was the final key to successfully bringing up the interface. By default, most Ethernet network interfaces operate at 1500 mtu. My server's physical 10G interface is configured for 9000 mtu. When the VLAN comes up with the command module, it takes a second or two for the VLAN interface to update its mtu to 9000. Without the pause, the jail's bridge would inherit the original 1500 mtu from the VLAN interface, then drop the VLAN interface from the bridge when the VLAN mtu was updated. In short, this allows the VLAN interface to settle before bringing up the jail.

command (start jail)

This module starts the jail. I can't remember why I added the loop. It might be left over from when I was troubleshooting the VLAN mtu issue.

template (DNS)

Using the template module, I configure the jail's DNS settings.

command (install packages)

The final check that everything was configured correctly is the command module that tries to install the initial packages. It calls the pkg utility from the host using jexec to install some baseline packages needed for Ansible. It also registers the result and will continue looping 100 times until the command's exit code is "0". Until then, it will keep trying every 10 seconds. During this time, I can correct any configuration errors on my part (switch VLAN config, firewall VLAN interface, NAT settings, etc...)


💡
For more information on how the jails are configured and details of the `/etc/jail.conf` file, checkout my previous post.