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.opnsensecollection 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 --diffand 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-core2.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). httpxinstalled 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: localkeeps Ansible from trying to reach the firewall directly. Without it the play fails before any module runs.module_defaultsundergroup/ansibleguy.opnsense.allsupplies 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, andinterfaceas 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_aliasis present with the IP192.0.2.50and the description. - Firewall → Rules → LAN: the rule with description
CHG-2026-LAB: lab test rule using lab_test_aliasis 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.opnsense1.2.16 incollections/requirements.yml. - The playbook lints cleanly.
- The dry-run shows the expected diff (the new alias and the new rule).
- The apply reports
changed=2on the first run andchanged=0on 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.opnsensewraps 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.latestis 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.