get_port_forwards reads /api/firewall/d_nat/search_rule and keeps only what
the contract asks for: rules on an interface with an upstream gateway (the
WAN, and a second uplink as well). Internal redirects, anti-lockout rules
(nordr) and rules the captive portal generates are left out -- on the first
real box (OPNsense 26.7) that was 20 of 22 rules, and each would have made an
internal host look reachable from the internet.
Targets resolve through host/network aliases, one entry per address; an
interface address or a DNS name gives no address and the rule is skipped
rather than put on a guessed host. Ports resolve as numbers, the start of a
range, port aliases or service names; no port is every port (0), and tcp/udp
is two entries. The filtering is pure, in port_forwards.py, and the driver
method does the three reads.
A box without the destination-NAT API raises instead of answering "nothing
forwarded", which nobody checked.
`firmware/remove` acts on the OPNsense plugin set, and `get_packages`
reads the same list — so software installed as a plain FreeBSD package is
invisible to the one and unreachable by the other. The Wazuh agent is
exactly that, on a driver the agent plugin lists as supported.
Observed during a fleet-wide rollback on 2026-09-19: the `gw` device
could not be handled through netOrk at all, and the request posted for it
could never have succeeded.
A name that is not a plugin now raises NotImplementedError rather than
being POSTed. A request that cannot work reports failure for the wrong
reason and sends whoever reads it looking in the wrong place; netOrk
turns NotImplementedError into a 501, which is the accurate answer.
Reaching plain packages would need shell access, and the credentials
stored for these devices are frequently API-key only — that is a decision
of its own, not a detail of this one.
The injection guard still runs first: a malformed name is a ValueError
before anything asks whether it is a plugin.
netork#241
Every ping against a live OPNsense failed:
ping job creation failed for 10.30.0.1: {'result': 'failed',
'validations': {'ping.settings.hostname': 'A value is required.'}}
_ping_model_node read GET /api/diagnostics/ping/get and took the single
dict-valued key as the node to post under. The real model nests two levels:
{"ping": {"settings": {"hostname": "", "fam": {"ip": {...}, "ip6": {...}},
"source_address": "", "packetsize": "", ...}}}
so the helper answered "ping" and the job was created with the fields sitting
where the settings node belongs. The hostname never arrived, and the firewall
said so on every single call.
_ping_model_path walks the whole chain of single-dict wrappers and stops at the
first level holding more than one key — the field level, where fam is a dict
too and one more step would land inside a form field. _wrap_in_model nests the
settings accordingly, so a one-level model keeps working and the default, for
when /get cannot be read, is what current firmware ships.
The tests missed this because FakePingAPI answered /get with a one-level model
and read the posted payload back through the same assumption: the fake agreed
with the code about a shape neither of them shares with a device. It now speaks
what an OPNsense speaks, reads the payload through the model path, and a second
test keeps the one-level case covered.
Verified against a live firewall: the job is accepted ({"result": "ok"}) and
10.30.0.1 answers 3 of 3 at 0.116 ms.
Closes#3
Everything this driver does runs over the OPNsense REST API; there is no SSH
session. netOrk kept that fact in two hardcoded driver-name sets on its side
(netork#113) and now reads it from the driver.
The inventory collapsed rows that shared a name, domain, address and type,
which was meant to hide the alias records searchhostoverride lists alongside
their parent. It hid genuine duplicates too — and since sync_dns_zone deletes
what the inventory tells it about, it could only ever remove one copy per name
before adding a fresh one. A pile of identical overrides could grow but never
shrink.
An office firewall reached fifteen identical A records for its own name, all
tagged [netork], while netOrk's database showed one.
OPNsense flags alias rows with isAlias, so filter on that and report every
remaining row under its own UUID. Releases that predate the flag give no way to
tell an alias from a copy, so the content collapse stays as a fallback there.
sync_dns_zone now also pushes one override per logical record, because two
netOrk rows for one host is a state its database can legitimately be in.
The auto-PTR flag is read from addptr, which is what OPNsense 26.1 returns;
ptrrecord was absent from every row, so the default made every A record claim
it managed a PTR — and netOrk derives reverse-zone entries from exactly that
flag. addptr now goes out on writes alongside the legacy name.
Refs christianmanivong/netork#103, christianmanivong/netork#107
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.
get_interfaces() keys entries by the physical device ("em0"), which is what
every other call in this driver speaks. Wake-on-LAN is the exception: it needs
the name OPNsense assigned ("lan", "opt1") and silently rejects anything else
with an empty {} at HTTP 200.
The overview export already carries it, so pass it through as "identifier".
Empty for interfaces OPNsense has not assigned.
Part of netork#85. Fills in the three device-specific methods the new
DhcpServerMixin subnet layer expects.
searchSubnet only carries uuid/subnet/description, so get_dhcp_subnets
follows each row with getSubnet for the option data. That is one request per
subnet; a firewall serves a handful, so the round trips cost less than the
reconfigure they help avoid. An option Kea does not carry is omitted rather
than reported as empty, because the generic diff reads an absent key as
"not managed" — reporting [] would make every unmanaged option look like a
pending change.
apply_dhcp_subnet honours the mixin's partial-update contract: on an update
it reads the subnet's current options first and replaces only the named
ones. Without that, managing domain_search alone would blank the routers Kea
autocollected and strand every client on that VLAN without a gateway.
Setting any option also forces option_data_autocollect off — left on, Kea
keeps re-filling routers/DNS/NTP and the next diff sees a change again,
which is a reconfigure loop rather than a converged state.
OPNsense renders repeatable option fields as comma-separated strings in some
versions and as a selection map in others, for the same logical field. Both
shapes are accepted rather than pinning the driver to one release. Pools are
a newline-separated text block.
A subnet whose detail fetch fails is skipped with a log line instead of
aborting, same rule as get_dhcp_reservations: one broken record must not
make the whole inventory unreadable.
16 new tests. Not yet verified against a live device — no reachable OPNsense
at the time of writing, same caveat the reservation support shipped with.
Fills in the three device-specific methods so the generic diff/apply from
napalm-device-types works against OPNsense: searchReservation for the read,
addReservation/setReservation for the write, service/reconfigure for the
commit.
Until now the driver could only create and delete reservations as a
side-effect of VM provisioning, and never read them back — so there was no
way to see what a firewall already had.
Kea's `subnet` field on a reservation is a model relation that comes back as
the related subnet's CIDR in some versions and as its UUID in others. Both
are accepted and normalised to a CIDR; an unresolvable relation degrades to
an empty string rather than raising, so one orphaned entry cannot make the
whole inventory unreadable.
apply_dhcp_reservation deliberately does not reconfigure: that is the
commit's job, so a batch costs one daemon reload instead of one per entry.
Verified against mocked Kea responses only — no live OPNsense was available
at the time of writing. The CIDR-vs-UUID branch in particular is defensive
rather than empirically confirmed.
OPNsense has no synchronous ping endpoint. /api/diagnostics/ping is a job API —
create, start, read statistics, stop, remove — so a single ping costs five
requests and roughly a second of waiting, which makes the generic per-host
sweep from napalm-device-types unusable for a whole subnet.
The override 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. A batch (32 by default) is created and started, waited
for once, harvested with a single request and then cleaned up, so waiting time
per batch is constant rather than linear in hosts. PING_SWEEP_MAX_TARGETS
bounds the sweep as a whole — this runs on production firewalls.
Two details the API forces: results are polled, because search_jobs signals the
running ping with SIGINFO and then parses whatever it has written so far, so
the first read of a healthy host can still show zero probes; and the model's
root node is read from /get rather than hardcoded, so a rename in a future
OPNsense release cannot silently break job creation.
Tested against mocked API responses only — no live device is reachable at the
moment, so the endpoint shapes come from the OPNsense sources (PingController,
scripts/interfaces/ping.py).
Verified against a live OPNsense 24.7 instance: GET
/api/interfaces/overview/export returns a bare list of interface dicts
keyed by "device" with CIDR "addr4"/"addr6" strings, matching what
get_interfaces_ip() already parses. The fixture's "items"/"interface"/
"address"/"prefix" shape never matched, so all four TestGetInterfacesIp
tests failed regardless of driver correctness.
OPNsense-specific half of the FirewallDriver diff/apply mechanism added
in napalm-device-types: translates the vendor-neutral rule dict into the
/api/firewall/filter/addRule or setRule/<uuid> payload (string "1"/"0"
booleans, empty interface = floating rule -- same shape as the existing
SNMP self-provisioning rule in _action_fix_snmp), and commit_firewall_rules
reloads the filter via /api/firewall/filter/apply. get_firewall_rules()
already returns compatible field names, no changes needed there.
get_device_warnings() now returns only {code, meta} — severity, title,
message, and action are resolved centrally by netork's
WARNING_CATALOG (netork/core/device_warnings.py), not by the driver.
Keeps this driver independent of netork and avoids per-vendor drift in
how the same warning code is presented.
add_client/add_user's response carries no id, so create_radius_client()
and create_radius_user() now look the new entry up via
get_radius_clients()/get_radius_users() (matched by name/username)
immediately after creation. Callers need this id to address the entry in
later set_*/del_* calls -- without it there was no way to store a
reference to what was just created.
get/create/delete_radius_client and get/create/delete_radius_user, backed
by /api/freeradius/{client,user}/{search,add,del}_* and a reconfigure call
to apply changes. Endpoints and field names (client.ip, not ipaddr) verified
against a live OPNsense 24.7 instance via a real add -> search/get -> set ->
del round trip, cleaned up immediately after.
Verified against a live OPNsense 24.7 instance: the service id is
"ddclient" but the REST module is "dyndns" (/api/ddclient/* all 404).
Scoped to enabled/running only -- no ddclient/dyndns account was
configured on the test device to verify a per-account "registered IP"
shape against, so that comparison is deliberately left out rather than
guessed.
Reads certificates via POST /api/trust/cert/search, normalising each row
to {name, issuer, valid_from, valid_to, in_use_by}. Field mapping (Unix
timestamps for validity, %caref for the resolved issuer label) verified
against a live OPNsense 24.7 instance. Never surfaces crt_payload/
prv_payload/csr_payload -- those carry private key material.
Calls POST /api/wol/wol/set with no uuid in the payload, which makes
the os-wol plugin's WolController::setAction validate and wake
immediately without persisting a host to config.xml. Requires the
os-wol plugin installed and the target interface to have a static
IPv4 (OPNsense derives the broadcast address from the interface's own
IP/subnet). Endpoint/payload verified against the plugin's source
(opnsense/plugins net/wol), not guessed.
The lease-delete call never actually worked: it posted {"ip-address": ip}
to /api/kea/leases4/delLease, both wrong. Verified live against a real
OPNsense instance while cleaning up stale leases left by failed NetOrk VM
provisioning attempts — every call returned {"status": "error", "message":
"Missing lease IP parameter"} despite three different body-parameter
guesses (ips as list, ips as string, ip singular). The official API docs
(docs.opnsense.org/development/api/core/kea.html) show LeasesController as
"Abstract [non-callable]" with a del_lease($ips=null) action; despite that
signature looking like a body field, the concrete leases4 route only
accepts the IP as a URL path segment: POST /api/kea/leases4/del_lease/{ip}
confirmed {"status": "ok"} and the lease actually gone from a follow-up
search.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Combined removal of a static reservation and its active lease, needed by
NetOrk's VM-deletion cleanup flow. Reservation deletion follows the same
search-then-del<X>/{uuid} + reconfigure pattern as create_dhcp_reservation
and raises on failure; lease deletion is best-effort/non-fatal since the
Kea lease-delete endpoint shape is unverified against a real box.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Only Kea (os-kea plugin) is supported — no active OPNsense environment
with legacy ISC DHCP was available to verify a second code path against.
Payload/response shapes (searchSubnet, searchReservation, addReservation,
setReservation, delReservation, service/reconfigure) were confirmed
against a real OPNsense box via a live add + verify + delete cycle
before writing this method and its tests.
Without an explicit listen address, os-net-snmp on OPNsense may not
respond on non-loopback interfaces. The fix sets
listen = {self.hostname: {"selected": 1}} which is always the
management IP used to reach this device in netOrk.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Calling firmware/install on an already-installed os-net-snmp plugin
triggers an async reinstall that overwrites the config with factory
defaults a few minutes later — causing SNMP to stop working again.
Now checks /api/netsnmp/general/get first and only installs if the
plugin is genuinely absent.
Also adds a lightweight UDP/161 probe to verify SNMP is actually
reachable after the fix (falls back to API config check if the
socket probe is unavailable).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OPNsense default-drops traffic arriving on non-LAN interfaces (e.g.
WireGuard tunnels used as management networks). Even with os-net-snmp
running and configured, SNMP is unreachable from external management
hosts because no firewall rule allows it.
Now adds a floating pass rule for UDP/161 → (self) after configuring
the service, then applies the firewall. Skips the rule if one with
the same description already exists (idempotent).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- _get_unbound_host_overrides: deduplicate by (hostname, domain, ip, rr)
to suppress alias rows that OPNsense returns alongside parent records
- _get_unbound_host_overrides: read ptrrecord field so callers know which
A records have an auto-managed PTR in the reverse zone
- sync_dns_zone: set ptrrecord=1 when creating A/AAAA host overrides so
OPNsense Unbound manages the PTR record internally
- sync_dns_zone: refuse arpa zone names with ValueError — PTR records
must never be written back via the host override API
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Routed subnets on 802.1Q sub-interfaces now report their vlan_id so
callers can associate a subnet with the VLAN it belongs to. None for
untagged interfaces.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Protokoll-Erkennung aus flags: S=static, kein Gateway=connected, sonst=kernel
- Optionale OSPF-Anreicherung via /api/quagga/ospf/routes (FRR)
- family-Feld (ipv4/ipv6) aus Netzadresse abgeleitet
- link#X und 0.0.0.0 als Next-Hop bereinigt
- API-Response kann Liste oder Dict sein (beide Formate unterstützt)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Das /api/interfaces/addresses/export Endpoint existiert nicht auf allen
OPNsense-Versionen. Stattdessen wird /api/interfaces/overview/export
genutzt (gleiche Quelle wie get_interfaces()), um addr4/addr6 zu parsen.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
get_snmp_config() calls GET /api/netsnmp/general/get with 5s timeout (plugin
may not be installed). fix_snmp installs os-net-snmp package, configures via
POST /api/netsnmp/general/set with community 'public', restarts service.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>