20 Commits
Author SHA1 Message Date
Christian Manivong 3506f20606 feat: port forwards, read from destination NAT on the WAN
CI / test (3.10) (push) Failing after 1m39s
CI / test (3.11) (push) Failing after 25s
CI / test (3.12) (push) Failing after 12s
CI / test (3.9) (push) Failing after 31s
CI / test (3.10) (pull_request) Failing after 13s
CI / test (3.11) (pull_request) Failing after 12s
CI / test (3.12) (pull_request) Failing after 13s
CI / test (3.9) (pull_request) Failing after 12s
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.
2026-10-03 16:30:38 +02:00
Christian Manivong 4dd0fc2aee fix: say what uninstall_package cannot reach, instead of posting anyway
CI / test (3.10) (push) Failing after 1m10s
CI / test (3.11) (push) Failing after 1m7s
CI / test (3.12) (push) Failing after 13s
CI / test (3.9) (push) Failing after 24s
`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
2026-09-20 22:44:04 +02:00
Christian Manivong 5d193ba7e8 fix(ping): post the settings where the API expects them, not one node above
CI / test (3.11) (push) Failing after 7s
CI / test (3.10) (push) Failing after 12s
CI / test (3.9) (push) Failing after 6s
CI / test (3.12) (push) Failing after 18s
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
2026-08-22 15:44:56 +07:00
Christian Manivong d27c32096d fix(dns): report every host override so duplicates can be cleared
CI / test (3.10) (push) Failing after 21s
CI / test (3.11) (push) Failing after 21s
CI / test (3.12) (push) Failing after 26s
CI / test (3.9) (push) Failing after 14s
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
2026-08-20 23:35:04 +07:00
Christian Manivong 960aaefa13 fix(dhcp): read the Kea subnet record from subnet4, not subnet
CI / test (3.10) (push) Failing after 8s
CI / test (3.11) (push) Failing after 8s
CI / test (3.12) (push) Failing after 13s
CI / test (3.9) (push) Failing after 8s
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.
2026-08-20 11:49:16 +07:00
Christian Manivong 0c5670981d feat(interfaces): report the assigned interface name alongside the physical one
CI / test (3.10) (push) Failing after 8s
CI / test (3.11) (push) Failing after 7s
CI / test (3.12) (push) Failing after 10s
CI / test (3.9) (push) Failing after 8s
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.
2026-08-20 11:10:25 +07:00
Christian Manivong 8ba95a0709 feat(dhcp): implement subnet get/apply/commit against Kea DHCPv4
CI / test (3.10) (push) Failing after 8s
CI / test (3.11) (push) Failing after 8s
CI / test (3.12) (push) Failing after 10s
CI / test (3.9) (push) Failing after 8s
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.
2026-08-20 07:21:55 +07:00
Christian Manivong 1eb378c04c feat(dhcp): implement DhcpServerMixin against Kea DHCPv4
CI / test (3.10) (push) Failing after 10s
CI / test (3.11) (push) Failing after 8s
CI / test (3.12) (push) Failing after 10s
CI / test (3.9) (push) Failing after 7s
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.
2026-08-19 07:24:48 +07:00
Christian Manivong f934cc0cfa feat(ping): implement ping and a batched ping_sweep over the diagnostics API
CI / test (3.10) (push) Failing after 19s
CI / test (3.11) (push) Failing after 8s
CI / test (3.12) (push) Failing after 11s
CI / test (3.9) (push) Failing after 8s
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).
2026-08-13 16:51:05 +07:00
Christian Manivong 1f29d9d57d fix(tests): correct ADDRESSES_RESPONSE fixture shape in TestGetInterfacesIp
CI / test (3.10) (push) Failing after 19s
CI / test (3.11) (push) Failing after 7s
CI / test (3.12) (push) Failing after 7s
CI / test (3.9) (push) Failing after 7s
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.
2026-07-24 10:26:01 +02:00
Christian Manivong 8d3c443159 feat(firewall): implement apply_firewall_rule + commit_firewall_rules
CI / test (3.10) (push) Failing after 7s
CI / test (3.11) (push) Failing after 7s
CI / test (3.12) (push) Failing after 8s
CI / test (3.9) (push) Failing after 7s
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.
2026-07-20 15:05:53 +02:00
Christian Manivong 20d9d9651a fix(freeradius): return the created entry's remote id from create_radius_*
CI / test (3.10) (push) Failing after 8s
CI / test (3.11) (push) Failing after 7s
CI / test (3.12) (push) Failing after 7s
CI / test (3.9) (push) Failing after 7s
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.
2026-07-15 15:30:06 +02:00
Christian Manivong e51607a020 feat(freeradius): add NAS client and user CRUD driver methods
CI / test (3.10) (push) Failing after 7s
CI / test (3.11) (push) Failing after 7s
CI / test (3.12) (push) Failing after 8s
CI / test (3.9) (push) Failing after 7s
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.
2026-07-15 15:26:05 +02:00
Christian Manivong c8caa14176 feat(dyndns): add get_ddns_status() for os-ddclient enabled/running state
CI / test (3.10) (push) Failing after 8s
CI / test (3.11) (push) Failing after 7s
CI / test (3.12) (push) Failing after 7s
CI / test (3.9) (push) Failing after 7s
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.
2026-07-15 13:52:20 +02:00
Christian Manivong 26470676ce feat(trust): add get_certificates() for Trust store certificate inventory
CI / test (3.10) (push) Failing after 8s
CI / test (3.11) (push) Failing after 7s
CI / test (3.12) (push) Failing after 8s
CI / test (3.9) (push) Failing after 7s
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.
2026-07-15 12:26:36 +02:00
Christian Manivong 62424cfd71 feat(opnsense): implement send_wake_on_lan() via the os-wol plugin API
CI / test (3.10) (push) Failing after 7s
CI / test (3.11) (push) Failing after 8s
CI / test (3.12) (push) Failing after 7s
CI / test (3.9) (push) Failing after 7s
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.
2026-07-12 11:17:48 +02:00
Christian ManivongandClaude Sonnet 5 b8a68fc3a8 fix(opnsense): correct Kea leases4 del_lease endpoint — path param, not body
CI / test (3.10) (push) Failing after 7s
CI / test (3.11) (push) Failing after 7s
CI / test (3.12) (push) Failing after 8s
CI / test (3.9) (push) Failing after 7s
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>
2026-07-09 10:31:05 +02:00
Christian ManivongandClaude Sonnet 5 b8dac1db63 feat(opnsense): add delete_dhcp_reservation_and_lease() for Kea DHCPv4
CI / test (3.10) (push) Failing after 7s
CI / test (3.11) (push) Failing after 7s
CI / test (3.12) (push) Failing after 7s
CI / test (3.9) (push) Failing after 7s
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>
2026-07-08 19:24:04 +02:00
Christian Manivong 806a23018d feat(opnsense): add create_dhcp_reservation() for Kea DHCPv4 static mappings
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.
2026-07-08 09:09:41 +02:00
Christian Manivong ae27cd5469 initial commit 2026-05-29 09:22:10 +02:00