getSubnet wraps its record under `subnet4`. The driver read `subnet`, got
nothing, and carried on:
- get_dhcp_subnets() reported every subnet with no pools, no options and no
description. Only the CIDR survived, and only because it falls back to the
searchSubnet row. Confirmed against a live OPNsense serving six subnets:
all six came back with empty pools while the device had
"10.10.0.100-10.10.0.250" and routers/DNS/NTP set on each.
- apply_dhcp_subnet() read the same key to merge the options it was not
asked to change. An empty record means nothing to preserve, so updating a
subnet with only domain_search set would have written back only that one
option and blanked the routers Kea autocollected — stranding every client
on that VLAN without a gateway. That is precisely the failure the merge
exists to prevent.
The unit fixtures encoded the wrong shape, which is why the safety test
test_unnamed_options_are_preserved_on_update passed while the real thing was
broken. They now carry the response captured from OPNsense 25.x, and correcting
them turns that test red against the old parse.
Both call sites go through _kea_subnet_record(), which prefers `subnet4` and
falls back to `subnet` for older builds.
napalm-opnsense
NAPALM community driver for OPNsense firewalls (read-only, via REST API).
Tested devices
| Model | OPNsense Version | Tested |
|---|---|---|
| OPNsense (virtual/bare metal) | 24.7 | ✅ |
Additional OPNsense versions should work — contributions welcome.
Requirements
| Dependency | Minimum version |
|---|---|
| Python | 3.9 |
| NAPALM | 4.0 |
| requests | 2.28 |
Installation
pip install napalm-opnsense
Or from source:
git clone https://github.com/napalm-automation-community/napalm-opnsense
cd napalm-opnsense
pip install -e .
Quick start
from napalm import get_network_driver
driver = get_network_driver("opnsense")
with driver(
"192.168.1.1",
"my_api_key",
"my_api_secret",
optional_args={"verify": False},
) as device:
facts = device.get_facts()
print(facts)
Authentication
OPNsense uses API key/secret pairs instead of username/password. Generate a key pair in the OPNsense GUI under System → Access → Users → edit user → API keys.
Pass the credentials via optional_args:
optional_args={
"api_key": "your-api-key",
"api_secret": "your-api-secret",
"verify": False, # set to a CA bundle path or True in production
}
Alternatively, pass the key/secret as the positional username/password
arguments.
Implemented getters
| Getter | Status | OPNsense API Endpoint |
|---|---|---|
get_facts |
✅ | GET /api/core/system/status |
get_interfaces |
✅ | GET /api/interfaces/overview/export |
get_interfaces_ip |
✅ | GET /api/interfaces/addresses/export |
get_interfaces_counters |
✅ | GET /api/diagnostics/interface/get_interface_statistics |
get_arp_table |
✅ | GET /api/diagnostics/interface/get_arp |
get_ipv6_neighbors_table |
✅ | GET /api/diagnostics/interface/get_ndp |
get_route_to |
✅ | GET /api/diagnostics/interface/get_routes |
get_environment |
✅ | GET /api/diagnostics/system/system_resources + system_temperature |
get_lldp_neighbors |
✅ ¹ | GET /api/lldpd/service/neighbor |
get_lldp_neighbors_detail |
✅ ¹ | GET /api/lldpd/service/neighbor |
get_ntp_servers |
✅ | GET /api/ntpd/service/status |
get_config |
✅ | GET /api/core/backup/download/this (XML) |
is_alive |
✅ | TCP socket check |
get_bgp_neighbors |
✅ ² | GET /api/quagga/bgp/get + GET /api/quagga/diagnostics/bgpneighbors |
get_vlans |
✅ | GET /api/interfaces/vlan_settings/search_item |
get_mac_address_table |
❌ | Not applicable (firewall, no L2 switching) |
ping |
✅ | POST /api/diagnostics/ping/set + start + search_jobs + stop/remove |
ping_sweep |
✅ ³ | same endpoints, one batch of parallel jobs at a time |
¹ Requires the
os-lldpdplugin. Returns empty dict if the plugin is not installed. ² Requires theos-frr(FRR/Quagga) plugin. Returns empty dict if the plugin is not installed or FRR is not running. ³ Overrides the generic per-host loop fromnapalm-device-types.
Ping
OPNsense has no synchronous ping endpoint: /api/diagnostics/ping is a job
API — create, start, read statistics, stop, remove. A single ping therefore
costs five requests and about a second of waiting, which makes the generic
sequential sweep from napalm-device-types unusable for a whole subnet.
ping_sweep exploits what the job API does offer instead: jobs are
independent and run on the firewall in parallel, and search_jobs reports all
of them in one response. It creates and starts a batch
(PING_SWEEP_BATCH_SIZE, default 32), waits once, reads every result with a
single request, then cleans the batch up — waiting time per batch is constant
rather than linear in hosts. PING_SWEEP_MAX_TARGETS (default 512) bounds the
sweep as a whole; both are deliberately conservative, since this runs on
production firewalls.
Results are polled rather than read once: search_jobs signals the running
ping with SIGINFO and parses whatever it has written so far, so the
statistics line lands in the log slightly after the request that triggered it.
ttl and vrf are accepted for NAPALM compatibility and ignored — the API
has no equivalent.
Config management
OPNsense does not expose a single generic "push config" endpoint. Config is
managed per-module via separate API controllers. This driver implements config
management for static routes via /api/routes/routes/.
Why routes? Routes are the most common network-automation target on a firewall, and the OPNsense routes API provides full CRUD operations.
Supported methods
| Method | OPNsense API |
|---|---|
load_merge_candidate(config=...) |
stages routes in memory |
compare_config() |
diffs against GET /api/routes/routes/searchroute |
commit_config() |
snapshots backup → POST /api/routes/routes/addroute × n → reconfigure |
discard_config() |
clears staged candidate |
rollback() |
POST /api/core/backup/revert_backup/{id} — restores the config.xml snapshot taken before the last commit |
Config format
The load_merge_candidate config parameter must be a JSON array of route
objects, each with network and gateway keys. gateway must be the
name of an existing OPNsense gateway (as configured under
System → Gateways → Configuration), not an IP address.
[
{
"network": "10.0.0.0/8",
"gateway": "WAN_GW",
"descr": "Corporate internal",
"disabled": "0"
},
{
"network": "0.0.0.0/0",
"gateway": "WAN_GW",
"descr": "Default route"
}
]
Example
import json
driver = get_network_driver("opnsense")
d = driver("192.168.1.1", "user", "pass", optional_args={"api_key": "k", "api_secret": "s"})
d.open()
routes = json.dumps([{"network": "10.0.0.0/8", "gateway": "WAN_GW"}])
d.load_merge_candidate(config=routes)
print(d.compare_config()) # unified diff
d.commit_config() # applies routes and calls reconfigure
d.rollback() # removes the routes just added
d.close()
Limitations
load_replace_candidateis not supported (no XML upload endpoint in the JSON API).- Non-route config (firewall rules, DHCP, DNS, interfaces, …) must be managed via OPNsense module-specific controllers — outside the scope of this driver.
rollback()reverts the entireconfig.xmlto the pre-commit state (not just the routes). OPNsense's backup API has no partial-restore capability.- Rollback uses the backup snapshot taken at
commit_config()time. If no commit was made in the current session, the most recent available backup is used as a fallback.
Optional arguments
| Argument | Default | Description |
|---|---|---|
api_key |
username |
OPNsense API key |
api_secret |
password |
OPNsense API secret |
base_url |
https://<hostname> |
Override the base URL |
verify |
True |
TLS certificate verification (path or bool) |
Development
pip install -e ".[dev]"
pytest tests/
CI
This project includes a GitHub Actions workflow that:
- Runs unit tests across Python 3.9–3.12
- Builds sdist and wheel
- Uploads build artifacts
License
Apache 2.0 — see LICENSE.