Skip to main content
RunBook Academy

← All labs in OPNsense

Lab · advanced · ~90 min

Lab: Push an OPNsense configuration with Ansible

B · Nested virtualisationC · Simulation

Objectives

  • Install the ansibleguy.opnsense collection and pin a known version
  • Build an inventory and group variables for a single firewall
  • Write a playbook that adds an alias and a firewall rule
  • Run the playbook with --check --diff on a canary host and verify the diff matches expectations
  • Apply the change for real and verify it is loaded into PF
  • Demonstrate idempotency by re-running the playbook and confirming no second change

Prerequisites

This lab uses the ansibleguy.opnsense collection to push a configuration change to a firewall. The change is small (an alias and a rule), but the discipline is the same for every change that crosses an estate: pin the collection, lint the playbook, dry-run with --check --diff, apply to a canary, verify in PF, and demonstrate idempotency with a second run that reports no changes.

By the end you will have an Ansible project that an automation platform can pick up and run against a fleet, and you will have the discipline to validate the project before any production run.

Objective

By the end of this lab, you can:

  • Install the ansibleguy.opnsense collection and pin it to a known version.
  • Build an inventory and group variables for a single firewall.
  • Write a playbook that adds an alias and a firewall rule.
  • Run the playbook with --check --diff and verify the diff matches expectations.
  • Apply the change for real and verify the alias and rule are loaded into PF.
  • Demonstrate idempotency by re-running the playbook and confirming no changes.

Requirements

  • An Ansible controller (a Linux VM, a workstation, or a CI runner) with ansible-core 2.15 or newer.
  • An OPNsense instance reachable via the API.
  • A service-account API key and secret, on an account with the privileges to manage aliases and firewall rules.
  • Both halves of the credential stored outside source control (Vault, the CI secret store, or a local file with chmod 600).
  • httpx installed on the controller — the collection uses it for every API call.
  • Git for the project. Even for a lab, the discipline of version control pays off.

Tasks

Task 1: Create the project directory

mkdir -p ~/opnsense-ansible-lab
cd ~/opnsense-ansible-lab
git init
mkdir -p inventories/lab collections

Pin the collection in collections/requirements.yml:

cat > collections/requirements.yml <<'EOF'
---
collections:
  - name: ansibleguy.opnsense
    version: 1.2.16
EOF

The modules talk to the firewall’s API from the controller using the httpx Python library, so install that first:

python3 -m pip install --upgrade httpx

Then install the collection:

ansible-galaxy collection install -r collections/requirements.yml \
  -p collections/

Verify:

ls collections/ansible_collections/ansibleguy/opnsense/
ansible-galaxy collection list -p collections/ | grep opnsense

Task 2: Configure the Ansible search path

The collection is in a project-local directory. Set ANSIBLE_COLLECTIONS_PATH so ansible-playbook finds it:

cat > ansible.cfg <<'EOF'
[defaults]
collections_path = ./collections
inventory = inventories/lab/hosts.ini
host_key_checking = False
retry_files_enabled = False
stdout_callback = default
EOF

The collections_path line is the production discipline: the collection is project-local, not in the user’s default location.

Task 3: Build the inventory

The inventory is a single host (the canary firewall). For a production fleet, you would have many hosts grouped by role and location; the same patterns apply.

cat > inventories/lab/hosts.ini <<'EOF'
[opnsense]
fw-canary ansible_host=<firewall-ip-or-hostname>

[opnsense:vars]
ansible_connection=local
ansible_python_interpreter={{ ansible_playbook_python }}
opnsense_api_key={{ lookup('env', 'OPN_API_KEY') }}
opnsense_api_secret={{ lookup('env', 'OPN_API_SECRET') }}
opnsense_api_port=443
opnsense_ssl_verify=false
EOF

ansible_connection=local is not a lab simplification — it is how this collection always works. The modules run on the controller and reach the firewall over HTTPS, so Ansible must never try to open a connection to the host. The host entry exists to give the play a name, an address in ansible_host, and somewhere to hang variables. There is no httpapi connection plugin for OPNsense and no ansible_network_os value for it.

opnsense_ssl_verify=false is a lab concession to the self-signed certificate a fresh install ships with. In production leave verification on — it is the only assurance the controller has that it is configuring the firewall it thinks it is, while holding a credential that can rewrite the ruleset.

The key and secret come from the environment, so neither is ever in the repository:

set -a; . ~/.opnsense-lab/alias-api-key; set +a
export OPN_API_KEY="$key" OPN_API_SECRET="$secret"

Task 4: Verify the inventory

ansible-inventory --graph

The output should show the canary host under the opnsense group:

@all:
  |--@opnsense:
  |  |--fw-canary
  |--@ungrouped:

Confirm the credentials work. The collection’s list module is read-only, so it is the safe first call:

ansible opnsense -m ansibleguy.opnsense.list \
  -a "target=alias firewall={{ ansible_host }} \
      api_key={{ opnsense_api_key }} api_secret={{ opnsense_api_secret }} \
      ssl_verify=false"

The output should be a data list of the aliases the firewall already holds. A 401 in the error means the key or the secret is wrong; a 403 means they authenticated and the account lacks the privilege.

Task 5: Lint the playbook skeleton

Create the playbook now, even with empty tasks:

cat > playbook-lab.yml <<'EOF'
---
- name: 'Lab: add an alias and a rule via the OPNsense API'
  hosts: opnsense
  gather_facts: false
  vars:
    change_ticket: 'CHG-2026-LAB'
  tasks:
    - name: 'placeholder'
      ansible.builtin.debug:
        msg: 'lint check'
EOF

Lint:

ansible-lint playbook-lab.yml

The output should be clean (or show only the placeholder’s parameter-typing warnings). A clean lint pass is not sufficient but a noisy one is worth fixing.

Task 6: Write the playbook

Replace the playbook with the real one:

cat > playbook-lab.yml <<'EOF'
---
- name: 'Lab: add an alias and a rule via the OPNsense API'
  hosts: opnsense
  connection: local
  gather_facts: false
  module_defaults:
    group/ansibleguy.opnsense.all:
      firewall: '{{ ansible_host }}'
      api_key: '{{ opnsense_api_key }}'
      api_secret: '{{ opnsense_api_secret }}'
      api_port: '{{ opnsense_api_port }}'
      ssl_verify: '{{ opnsense_ssl_verify }}'
  vars:
    alias_name: 'lab_test_alias'
    alias_content:
      - '192.0.2.50'
    rule_description: 'CHG-2026-LAB: lab test rule using lab_test_alias'
    target_interface: 'lan'
  tasks:
    - name: 'Ensure alias lab_test_alias exists'
      ansibleguy.opnsense.alias:
        name: '{{ alias_name }}'
        type: 'host'
        content: '{{ alias_content }}'
        description: 'CHG-2026-LAB: lab test alias'
        state: 'present'

    - name: 'Ensure rule using the alias exists'
      ansibleguy.opnsense.rule:
        description: '{{ rule_description }}'
        match_fields: ['description']
        action: 'pass'
        interface: ['{{ target_interface }}']
        direction: 'in'
        ip_protocol: 'inet'
        protocol: 'any'
        quick: true
        source_net: '{{ alias_name }}'
        destination_net: 'any'
        log: true
        state: 'present'
EOF

Read the playbook carefully. Four things are doing real work:

  • connection: local keeps Ansible from trying to reach the firewall directly. Without it the play fails before any module runs.
  • module_defaults under group/ansibleguy.opnsense.all supplies the connection parameters to every module in the collection at once, so no task repeats the credentials.
  • match_fields: ['description'] is how the rule module decides whether an existing rule is this rule. It is required, and it is the reason the second run of this playbook will report no change rather than adding a duplicate.
  • The field names are the module’s, not the API’s: source_net, destination_net, ip_protocol, and interface as a list. Every object carries the change ticket in its description.

Lint again:

ansible-lint playbook-lab.yml

Task 7: Dry-run with —check —diff

The dry-run is the safety net. Each module reads the current state from the API and reports what it would change, without writing anything.

ansible-playbook playbook-lab.yml --check --diff

Inspect the output carefully. The changed field for each task should be true (because the alias and rule do not yet exist). The diff should show:

  • For the alias module: the alias name, type, content, and description.
  • For the rule module: the rule description, action, interface, source, destination.

If the diff does not match what you expected — wrong interface, wrong source, wrong destination — fix the playbook before applying. The dry-run is the cheap place to find mistakes.

Task 8: Apply the change

ansible-playbook playbook-lab.yml

The output should show changed: true for both tasks. The failed count should be 0. The changed count should be 2.

Save the output:

ansible-playbook playbook-lab.yml | tee /tmp/apply-1.log

The summary at the bottom should show:

  • play recap: ok=2, changed=2, failed=0

Task 9: Verify the change in the GUI

Log in to the GUI and confirm:

  • Firewall → Aliases: the alias lab_test_alias is present with the IP 192.0.2.50 and the description.
  • Firewall → Rules → LAN: the rule with description CHG-2026-LAB: lab test rule using lab_test_alias is present with the alias as the source.

If either is missing, the playbook reported changed but the change did not land in the GUI. The recovery is to inspect the configctl log (/var/log/configd.log) and the API response.

Task 10: Verify the change in PF

The API write is the precondition; the PF load is the proof.

pfctl -t lab_test_alias -T show

The pf table takes the alias’s name, so this shows the table contents: 192.0.2.50.

pfctl -sr | grep lab_test_alias

The output should show the compiled rule:

pass in log quick on igb1 inet from <lab_test_alias> to any flags S/SA keep state label "9c8d7e6f-5a4b-3c2d-1e0f-9a8b7c6d5e4f"

Note what is and is not in that line. The alias appears by name, in angle brackets, because pf tables are named after aliases. The rule’s description does not appear at all — it is written into the generated ruleset as a comment and pf discards comments when it loads. The label is the rule’s UUID, and it is the only stable handle pf keeps.

That is worth knowing before you go looking: pfctl -sr | grep 'CHG-2026-LAB' returns nothing even when everything has worked perfectly.

Task 11: Demonstrate idempotency

Re-run the playbook:

ansible-playbook playbook-lab.yml

The output should show changed: 0 for both tasks. The modules read the current state, found it already matched, and wrote nothing.

This is the idempotency discipline. A playbook that runs twice and reports two changes on the second run is not idempotent — it is doing extra work, and that work might cause harm. The community OPNsense collection is supposed to implement idempotency; the re-run confirms it.

Save the output:

ansible-playbook playbook-lab.yml | tee /tmp/apply-2.log

The summary should show changed=0 for both runs.

Task 12: Demonstrate behavioural change

Add a second host to the alias by editing only the variable — the tasks do not change at all:

sed -i "/- '192.0.2.50'/a\\      - '192.0.2.51'" playbook-lab.yml
sed -n '/alias_content:/,/rule_description:/p' playbook-lab.yml

The variable block should now read:

    alias_content:
      - '192.0.2.50'
      - '192.0.2.51'

Run a dry-run:

ansible-playbook playbook-lab.yml --check --diff

The diff should show the alias content changing from a single IP to two IPs. The rule should not change (the source is still the alias, not the IP list).

Apply the change:

ansible-playbook playbook-lab.yml

The output should show changed=1 for the alias task and changed=0 for the rule task. Verify in PF:

pfctl -t lab_test_alias -T show

The output should show both IPs.

This is the second-order check: the playbook changes one object without touching the other. It also shows why match_fields matters. The rule task matches on description, which did not change, so the module recognised the existing rule and left it alone. Had the alias’s name been the thing edited instead, the rule would still have matched — but it would now point at an alias that no longer exists.

Task 13: Roll back

The lab is complete. Reverse the change by running the playbook with the original alias content:

# Edit the playbook to set alias_content to ['192.0.2.50']
# Then run:
ansible-playbook playbook-lab.yml

Then delete the alias and rule via the API (or via the GUI):

Delete the rule first and the alias second. The API refuses to delete an alias that a rule still references, so the order is not optional:

# In playbook-lab.yml, set state: 'absent' on both tasks —
# the rule task first, the alias task second — then run:
ansible-playbook playbook-lab.yml

Verify in pf:

pfctl -sr | grep lab_test_alias
# expected: empty
pfctl -t lab_test_alias -T show
# expected: pf reports no such table

Task 14: Commit the project (without the API key)

cat > .gitignore <<'EOF'
collections/ansible_collections/
inventories/lab/host_vars/
*.key
.env
EOF
git add ansible.cfg collections/requirements.yml \
        inventories/lab/hosts.ini playbook-lab.yml .gitignore
git commit -m 'lab: ansible playbook for OPNsense alias + rule'

The .gitignore ensures the local collection cache and the API key are never committed.

Validation

  • The collection is pinned to ansibleguy.opnsense 1.2.16 in collections/requirements.yml.
  • The playbook lints cleanly.
  • The dry-run shows the expected diff (the new alias and the new rule).
  • The apply reports changed=2 on the first run and changed=0 on the second run.
  • The alias is in the API, in the GUI, and in pf’s table of the same name.
  • The rule is in the API, in the GUI, and in pf’s compiled ruleset, findable by its UUID label.
  • The behavioural change (one IP vs two IPs) demonstrates that the playbook can modify one object without touching the other.
  • The rollback removes the alias and rule, and the diff against the pre-lab state is empty.
  • The project is in git, with the API key and the collection cache excluded.

Cleanup

The lab is largely self-cleaning. The remaining cleanup is the firewall state and the project directory.

# On the firewall, verify the alias and rule are gone
pfctl -t lab_test_alias -T show
pfctl -sr | grep lab_test_alias

# On the controller, remove the project directory
cd ~
rm -rf ~/opnsense-ansible-lab

# Remove the API key
shred -u ~/.opnsense-lab/alias-api-key
rm -rf ~/.opnsense-lab

What you learned

  • ansibleguy.opnsense wraps the REST API into Ansible modules. It is community-maintained, not first-party, and it is the most common production interface.
  • The modules run on the controller and authenticate with the API key/secret pair. The firewall needs no SSH access and nothing installed on it; the play needs connection: local.
  • Pin the collection version in requirements.yml. latest is not a pin.
  • Idempotency comes from match_fields. It defines how a module recognises the object it manages, and changing it — or changing a field it names — makes the module create duplicates instead of updating.
  • The discipline is the same for any change: lint, dry run, apply on a canary, verify in the kernel, demonstrate idempotency, roll back.
  • The key and the secret are both credentials. Store them in a secrets manager, never in the playbook or the repository.

Deliverables

  • · An Ansible project with a pinned collection version and a working inventory
  • · A playbook that adds an alias and a rule, with descriptions keyed to a change ticket ID
  • · A pre-apply dry-run diff that matches the expected change
  • · A post-apply verify that the alias and rule are in the API, in PF, and effective
  • · An idempotency demonstration: a re-run that reports no changes

Verification status

Last reviewed
2026-08-14
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.