Author SHA1 Message Date
christianmanivong 63ce6e78bd Merge pull request 'chore: adopt the NAPALM driver standard' (#31) from chore/driver-standard into main
driver / lint (push) Successful in 16s
driver / standard (push) Successful in 17s
driver / types (push) Successful in 1m6s
driver / test (3.10) (push) Successful in 1m8s
driver / test (3.11) (push) Successful in 1m8s
driver / lowest (push) Successful in 41s
driver / test (3.12) (push) Successful in 1m10s
driver / test (3.13) (push) Successful in 1m17s
driver / build (push) Successful in 35s
driver / security (push) Successful in 1m9s
driver / publish (push) Successful in 29s
CI / driver (push) Successful in 3m47s
2026-10-23 10:37:38 +00:00
Christian Manivong ae732fd7c8 chore(release): 0.5.0
driver / standard (pull_request) Successful in 18s
driver / lint (pull_request) Successful in 16s
driver / types (pull_request) Successful in 59s
driver / test (3.10) (pull_request) Successful in 1m50s
driver / test (3.11) (pull_request) Successful in 1m9s
driver / test (3.12) (pull_request) Successful in 50s
driver / test (3.13) (pull_request) Successful in 45s
driver / lowest (pull_request) Successful in 29s
driver / security (pull_request) Successful in 45s
driver / build (pull_request) Successful in 22s
CI / driver (pull_request) Successful in 7m45s
driver / publish (pull_request) Skipped
2026-10-23 10:29:08 +02:00
Christian Manivong 6e0e206e06 chore: adopt the NAPALM driver standard
driver / standard (pull_request) Successful in 15s
driver / lint (pull_request) Successful in 12s
driver / types (pull_request) Successful in 1m38s
driver / test (3.10) (pull_request) Successful in 1m1s
driver / test (3.11) (pull_request) Successful in 52s
driver / test (3.12) (pull_request) Successful in 49s
driver / test (3.13) (pull_request) Successful in 45s
driver / lowest (pull_request) Successful in 29s
driver / security (pull_request) Successful in 47s
driver / build (pull_request) Successful in 22s
CI / driver (pull_request) Successful in 7m11s
driver / publish (pull_request) Skipped
- CI moves to .gitea/workflows/ci.yml and calls the shared driver workflow
  of NAPALM/driver-standard; .github/ is gone.
- setuptools>=77 with the SPDX license string, no License classifier,
  Python 3.10-3.13 classifiers, napalm-device-types>=4.21.1, a dev extra
  with floors, links to git.netork.io, LICENSE file, py.typed.
- CHANGELOG.md in Keep-a-Changelog form; README names Python 3.10, the
  Gitea clone URL and the new CI.
- mypy strict for the package; exempt for now (52 errors): ha, listening,
  opnsense, ospf_mixin, ping_mixin, port_forwards, resolver_mixin.
  Recorded in the quality baseline with the long and complex functions,
  the 3100-line opnsense.py and one unannotated public method.
- Coverage floor 79 % (measured 79.20 %).
2026-10-23 08:35:15 +02:00
Christian Manivong 8815c4a584 fix: what bandit and the remaining lint rules found
- The module logger and the refresh constants of opnsense.py move below
  the imports (E402).
- Two long result messages are wrapped in parentheses (E501).
- nosec for the two wildcard addresses (B104): one is the address
  sockstat reports for "*", the other a route's gateway compared with
  0.0.0.0. Neither binds a socket; both are false positives.
2026-10-23 08:33:21 +02:00
Christian Manivong 571d9abb5f style: ruff format and the lint rules of the driver standard
Mechanical: ruff format, and ruff check --fix with the rule set every NAPALM
driver uses (E, F, I, UP, B at line length 100, Python 3.10). What ruff
cannot fix comes in the next commit.
2026-10-23 08:32:41 +02:00
christianmanivong 692a584fd2 Merge pull request 'fix: get_dhcp_leases raises when no lease source answered (#27)' (#29) from fix/dhcp-leases-raise-when-unread into master
CI / test (3.10) (push) Successful in 59s
CI / test (3.11) (push) Successful in 49s
CI / test (3.12) (push) Successful in 47s
2026-10-20 23:36:59 +00:00
christianmanivong 68dbd5891c Merge pull request 'fix: leave an unchanged Kea reservation and Kea alone' (#28) from fix/kea-skip-unchanged-reservation into master
CI / test (3.10) (push) Successful in 40s
CI / test (3.11) (push) Successful in 36s
CI / test (3.12) (push) Successful in 35s
2026-10-20 23:36:54 +00:00
Christian Manivong 5a6e573300 fix: get_dhcp_leases raises when no lease source answered (#27)
CI / test (3.10) (push) Successful in 32s
CI / test (3.11) (push) Successful in 34s
CI / test (3.12) (push) Successful in 33s
CI / test (3.10) (pull_request) Successful in 32s
CI / test (3.11) (pull_request) Successful in 29s
CI / test (3.12) (pull_request) Successful in 34s
When Kea, ISC DHCP and the ARP fallback all failed, get_dhcp_leases()
returned [], which a caller cannot tell from a server with no leases. It now
raises a RuntimeError naming each failure, and returns [] only when a source
answered with nothing. An empty ARP table after both lease sources failed
counts as unread too, since ARP rows are no lease data.

Closes #27
2026-10-21 01:01:52 +02:00
Christian Manivong 59796b3fa1 fix: leave an unchanged Kea reservation and Kea alone
CI / test (3.10) (push) Successful in 35s
CI / test (3.11) (push) Successful in 32s
CI / test (3.12) (push) Successful in 34s
CI / test (3.10) (pull_request) Successful in 31s
CI / test (3.11) (pull_request) Successful in 30s
CI / test (3.12) (pull_request) Successful in 34s
create_dhcp_reservation rewrote an existing reservation and called
service/reconfigure even when it already held exactly what was asked, e.g.
on a retried provisioning job. reconfigure stops and starts Kea (the Kea
service controller keeps reconfigureForceRestart() = true and configd has no
reload action), which once left a firewall without DHCP. A reservation whose
subnet, MAC (normalised), IP, hostname and description already match is now
left alone.

Refs NetOrk/netork#1187
2026-10-21 01:01:52 +02:00
christianmanivong 6a91f43fc3 Merge pull request 'feat: public methods for the upgrade, Unbound forwards and OSPF (0.4.0)' (#23) from feat/public-contracts-1113 into master
CI / test (3.10) (push) Successful in 31s
CI / test (3.11) (push) Successful in 28s
CI / test (3.12) (push) Successful in 30s
2026-10-18 13:14:03 +00:00
Christian Manivong fc4658f016 feat: public methods for the upgrade, Unbound forwards and OSPF (0.4.0)
CI / test (3.10) (push) Successful in 31s
CI / test (3.11) (push) Successful in 29s
CI / test (3.12) (push) Successful in 31s
CI / test (3.10) (pull_request) Successful in 31s
CI / test (3.11) (pull_request) Successful in 28s
CI / test (3.12) (pull_request) Successful in 30s
netOrk reached these through the private _get/_post (netOrk #1113); they
are napalm-device-types 4.17's contracts now:

- apply_updates passes the packages it is given (it dropped them) and
  get_update_progress reports the upgrade's state and log.
- get_dns_forwards, set_dns_forward (an existing forward of the domain
  is changed, not doubled) and remove_dns_forward, each applied with a
  reconfigure; a refusal is raised.
- get_ospf translates FRR's diagnostics into neutral terms.

Requires napalm-device-types 4.17.0.
2026-10-18 15:07:28 +02:00
christianmanivong 7183da24fb Merge pull request 'feat: drop get_device_warnings; the pending updates are the fact (0.3.0)' (#22) from feat/facts-not-warnings into master
CI / test (3.10) (push) Successful in 31s
CI / test (3.11) (push) Successful in 29s
CI / test (3.12) (push) Successful in 30s
2026-10-17 08:34:57 +00:00
Christian Manivong 5ab33d67f3 feat: drop get_device_warnings; the pending updates are the fact (0.3.0)
CI / test (3.10) (push) Successful in 30s
CI / test (3.11) (push) Successful in 28s
CI / test (3.12) (push) Successful in 30s
CI / test (3.10) (pull_request) Successful in 29s
CI / test (3.11) (pull_request) Successful in 28s
CI / test (3.12) (pull_request) Successful in 30s
updates_available came from the cached firmware status with ten package
names; netOrk builds it from get_available_updates with the full list
(netOrk #1111).
2026-10-17 09:38:34 +02:00
christianmanivong 39cc177bf2 Merge pull request 'fix!: the SNMP agent takes the caller's values and opens only to its sources (0.2.0)' (#21) from feat/snmp-agent-template into master
CI / test (3.10) (push) Successful in 30s
CI / test (3.11) (push) Successful in 31s
CI / test (3.12) (push) Successful in 34s
2026-10-17 06:48:18 +00:00
Christian Manivong c4edfc88e4 fix!: the SNMP agent takes the caller's values and opens only to its sources (0.2.0)
CI / test (3.10) (push) Successful in 38s
CI / test (3.11) (push) Successful in 32s
CI / test (3.12) (push) Successful in 30s
CI / test (3.10) (pull_request) Successful in 29s
CI / test (3.11) (pull_request) Successful in 28s
CI / test (3.12) (pull_request) Successful in 31s
fix_snmp enabled os-net-snmp with community "public", contact
netork@localhost and location "Managed by netOrk", and added a
floating pass rule for UDP/161 from any source on every interface: a
firewall managed over WAN answered its SNMP tree to anyone there (#13).

The driver implements napalm-device-types 4.13's configure_snmp_agent
steps instead:
- install: os-net-snmp only when missing (a reinstall resets it);
- configure: community, contact, location and listen address from the
  caller; a DNS name is not a listen address; a refused save fails;
- firewall: one floating pass rule per allowed source, the old
  any-source rule and rules of an earlier run replaced, operators'
  rules untouched; no sources, no change;
- start: restart net-snmp.
The hand-built UDP probe goes; the caller asks the agent itself.

BREAKING CHANGE: run_device_action is removed. Requires
napalm-device-types 4.13.

Closes #13
2026-10-17 08:26:15 +02:00
christianmanivong 9fe6245940 Merge pull request 'fix: name and version from system_information; a CARP address on its MASTER's interface' (#20) from fix/hostname-and-carp-addresses into master
CI / test (3.10) (push) Successful in 32s
CI / test (3.11) (push) Successful in 32s
CI / test (3.12) (push) Successful in 31s
2026-10-16 13:50:03 +00:00
Christian Manivong e3ed0f1bdd fix: name and version from system_information; a CARP address on its MASTER's interface
CI / test (3.10) (push) Successful in 30s
CI / test (3.11) (push) Successful in 29s
CI / test (3.12) (push) Successful in 30s
CI / test (3.10) (pull_request) Successful in 30s
CI / test (3.11) (pull_request) Successful in 28s
CI / test (3.12) (pull_request) Successful in 29s
get_facts read hostname and version from /api/core/system/status, which on
OPNsense 26.7 carries neither: every firewall read as nameless and "unknown",
and netOrk kept naming one by its address. /api/diagnostics/system/system_information
has both (name is the FQDN, versions[0] "OPNsense 26.7.7-amd64"). The status
answer still counts where the new one gives nothing.

get_interfaces_ip listed each interface's primary address only, so a CARP address
never showed as the holder's. It now lists an address bound to a VHID on the node
that is MASTER for it, and leaves it off a BACKUP -- as keepalived puts the address
on the master alone. netOrk's VIP badge sits on that interface (netOrk #1121).
2026-10-16 15:37:43 +02:00
christianmanivong 055ec3da71 Merge pull request 'feat: report Kea's HA peers and the firewall's config sync' (#19) from feat/dhcp-ha-config-sync into master
CI / test (3.10) (push) Successful in 37s
CI / test (3.11) (push) Successful in 33s
CI / test (3.12) (push) Successful in 33s
2026-10-16 13:32:24 +00:00
Christian Manivong e334dc0058 feat: report Kea's HA peers and the firewall's config sync
CI / test (3.10) (push) Successful in 32s
CI / test (3.11) (push) Successful in 33s
CI / test (3.12) (push) Successful in 34s
CI / test (3.10) (pull_request) Successful in 38s
CI / test (3.11) (pull_request) Successful in 37s
CI / test (3.12) (pull_request) Successful in 38s
get_dhcp_ha reads kea/dhcpv4/get (the ha section) and
kea/dhcpv4/searchPeer: whether Kea's HA hook is on, this firewall's peer
name -- the % twin when left at its default -- and each peer's name,
role and HA URL. get_config_sync reads core/hasync/get: the address this
firewall pushes its configuration to and the ticked sync items, mapped to
the service names get_services reports; items that are no service stay
under `unmapped`. Both raise when the request fails.

Configuration, not state: Kea's control agent, which knows whether the
pair is in hot standby right now, listens on localhost only.

Needs napalm-device-types 4.11.0 (ConfigSyncMixin). NetOrk/netork#1125.
2026-10-16 15:18:45 +02:00
christianmanivong 5a1aa66db4 Merge pull request 'fix: sync_dns_zone no longer copies an override made by hand' (#18) from fix/hand-made-override-copies into master
CI / test (3.10) (push) Successful in 36s
CI / test (3.11) (push) Successful in 35s
CI / test (3.12) (push) Successful in 33s
2026-10-16 09:55:28 +00:00
Christian Manivong 5b54174cc4 fix: sync_dns_zone no longer copies an override made by hand
CI / test (3.10) (push) Successful in 32s
CI / test (3.11) (push) Successful in 32s
CI / test (3.12) (push) Successful in 40s
CI / test (3.10) (pull_request) Successful in 38s
CI / test (3.11) (pull_request) Successful in 35s
CI / test (3.12) (pull_request) Successful in 38s
The records netOrk sends include what its poll read off the same
firewall, hand-made host overrides among them. sync_dns_zone deletes only
tagged overrides and then added every record again under the tag, so each
hand-made override ended up on the device twice (NetOrk/netork#1123).

An enabled, untagged override with the same name, type and address in the
zone now stands in for its record. A copy made earlier is tagged, so the
next sync deletes it and does not add it again.
2026-10-16 11:48:58 +02:00
christianmanivong 2af99995e9 Merge pull request 'feat: report the firewall's CARP addresses as virtual IPs' (#17) from feat/carp-virtual-ips into master
CI / test (3.10) (push) Successful in 30s
CI / test (3.11) (push) Successful in 28s
CI / test (3.12) (push) Successful in 31s
2026-10-16 09:21:22 +00:00
Christian Manivong c868d41322 refactor: the CARP state table lives with the driver that speaks CARP
CI / test (3.10) (push) Failing after 15s
CI / test (3.11) (push) Failing after 15s
CI / test (3.12) (push) Successful in 30s
CI / test (3.10) (pull_request) Successful in 30s
CI / test (3.11) (pull_request) Successful in 28s
CI / test (3.12) (pull_request) Successful in 29s
carp_virtual_ip() and its state table moved here from napalm-device-types
(4.10.1, NAPALM/napalm-device-types#39): under the agreed structure the base
package carries only the shape of the reading, and what a CARP state means is
protocol knowledge. Tests moved along (test_carp_states.py).
2026-10-16 11:11:45 +02:00
Christian Manivong 00172f7a69 feat: report the firewall's CARP addresses as virtual IPs
CI / test (3.10) (push) Successful in 30s
CI / test (3.11) (push) Successful in 29s
CI / test (3.12) (push) Successful in 30s
CI / test (3.10) (pull_request) Successful in 29s
CI / test (3.11) (pull_request) Successful in 28s
CI / test (3.12) (pull_request) Successful in 30s
get_virtual_ips() reads every address bound to a CARP VHID -- the CARP
address and IP aliases on its VHID alike -- from
/api/interfaces/overview/export, with this node's CARP state, so the reading
says who holds each address. interfaces/vip_settings would list the same
addresses with each VHID's CARP password; it is not read.

vip_manager and keepalived are Linux mechanisms and are None here. Needs
napalm-device-types 4.10.0 (carp_virtual_ip).

netOrk #1121.
2026-10-16 11:07:08 +02:00
christianmanivong e318c97c20 Merge pull request 'fix: get_config reads the backup as text, not JSON' (#12) from fix/get-config-xml into master
CI / test (3.10) (push) Successful in 37s
CI / test (3.11) (push) Successful in 37s
CI / test (3.12) (push) Successful in 39s
2026-10-11 16:35:58 +00:00
Christian Manivong 852c1d59df fix: get_config reads the backup as text, not JSON
CI / test (3.10) (push) Successful in 36s
CI / test (3.11) (push) Successful in 34s
CI / test (3.12) (push) Successful in 39s
CI / test (3.10) (pull_request) Successful in 32s
CI / test (3.11) (pull_request) Successful in 31s
CI / test (3.12) (pull_request) Successful in 33s
/api/core/backup/download/this answers with config.xml itself, but
get_config went through _get(), which calls .json(). That raised on
every real device, the except swallowed it, and "running" came back
empty. The unit tests replaced _get with a lambda, so they never saw it.

_get_text() returns the body as text. A failed read now raises instead
of returning an empty string, so the caller sees why it failed.

Closes #11
2026-10-11 18:35:44 +02:00
christianmanivong fa88850535 Merge pull request 'feat: report what listens, and which OPNsense service it is' (#10) from feat/listening-sockets into master
CI / test (3.10) (push) Successful in 29s
CI / test (3.11) (push) Successful in 27s
CI / test (3.12) (push) Successful in 29s
2026-10-07 05:24:06 +00:00
Christian Manivong 2fb1226734 feat: report what listens, and which OPNsense service it is
CI / test (3.10) (push) Successful in 40s
CI / test (3.11) (push) Successful in 30s
CI / test (3.12) (push) Successful in 31s
CI / test (3.10) (pull_request) Successful in 29s
CI / test (3.11) (pull_request) Successful in 28s
CI / test (3.12) (pull_request) Successful in 30s
get_listening_sockets reads /api/diagnostics/interface/get_socket_statistics:
netstat's sockets with sockstat's user, command and PID, collected by
configd as root, so no SSH is needed. A listening socket is one without a
peer (*:*); "*" is the any-address of the socket's family.

Which service: the socket's unit is a name of the firewall's own service
list, so netOrk can match it to the service. The command (cut to ten
characters by FreeBSD) matches a service name, the start of one, or one
of the daemons whose service is called otherwise (sshd is openssh, the
FRR daemons are frr, kea-ctrl-agent is kea-dhcp). lighttpd is the web UI,
or the captive portal when it runs as www. WireGuard's sockets belong to
the kernel and are told by the listen ports of /api/wireguard/service/show.

The fixture is a real OPNsense 26.7.5 firewall's answer, cut down, with
documentation addresses. For netOrk#673.
2026-10-07 07:20:27 +02:00
christianmanivong 8ce7ea52e7 Merge pull request 'feat: restart the firewall (reboot_host)' (#9) from feat/reboot-host into master
CI / test (3.10) (push) Successful in 30s
CI / test (3.11) (push) Successful in 34s
CI / test (3.12) (push) Successful in 41s
2026-10-06 11:05:44 +00:00
Christian Manivong a6c1d77791 feat: restart the firewall (reboot_host)
CI / test (3.10) (push) Successful in 36s
CI / test (3.11) (push) Successful in 30s
CI / test (3.12) (push) Successful in 34s
CI / test (3.10) (pull_request) Successful in 33s
CI / test (3.11) (pull_request) Successful in 33s
CI / test (3.12) (pull_request) Successful in 33s
netOrk asks a driver for reboot_host before it offers a restart, and the
OPNsense driver had none, so a firewall could not be restarted from netOrk
(netOrk #637). reboot_host asks the firmware API (POST
/api/core/firmware/reboot); a refusal raises.
2026-10-06 13:05:27 +02:00
christianmanivong b3e89f003c Merge pull request 'feat: read pending updates from the cached firmware status, and run the check on request' (#8) from feat/cached-update-reader into master
CI / test (3.10) (push) Successful in 28s
CI / test (3.11) (push) Successful in 28s
CI / test (3.12) (push) Successful in 29s
2026-10-05 22:20:11 +00:00
31 changed files with 4509 additions and 589 deletions

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
name: CI
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
jobs:
driver:
uses: NAPALM/driver-standard/.gitea/workflows/driver.yml@main
with:
standard-ref: main
secrets:
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
-48
View File
@@ -1,48 +0,0 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- name: Install package with dev extras
run: |
python -m pip install --upgrade pip
# napalm-device-types lives in git.netork.io/NAPALM, not on PyPI: without this
# pip looks there, finds an unrelated 0.1.0 and the job dies before any test.
python -m pip install "napalm-device-types @ git+https://git.netork.io/NAPALM/napalm-device-types.git"
python -m pip install -e ".[dev]"
- name: Run unit tests
run: |
python -m pytest -q --tb=short
- name: Build wheel and sdist
run: |
python -m pip install build
python -m build
- name: Upload dist artifacts
# v4 refuses to run on Gitea ("not currently supported on GHES").
uses: actions/upload-artifact@v3
with:
name: dist-${{ matrix.python-version }}
path: dist/*
+47
View File
@@ -0,0 +1,47 @@
# Changelog
All notable changes to this driver. The format follows
[Keep a Changelog](https://keepachangelog.com/), versions follow
[Semantic Versioning](https://semver.org/).
## [Unreleased]
## [0.5.0] - 2026-10-23
The first release on PyPI.
### Changed
- The driver follows the NAPALM driver standard
(https://git.netork.io/NAPALM/driver-standard): CI on Gitea runs ruff, mypy, the
tests on Python 3.10–3.13 and at the lowest declared versions, bandit, pip-audit and
a build check, and a release tag publishes to PyPI. The package now ships `py.typed`
and a LICENSE file. Python 3.10 remains the floor. Requires napalm-device-types
4.21.1.
### Fixed
- `get_dhcp_leases()` raises when Kea, ISC DHCP and the ARP fallback all failed,
instead of returning an empty list that looks like a server without leases (#27).
- `create_dhcp_reservation()` leaves a reservation that already holds what was asked
alone, and does not reconfigure (restart) Kea for it.
## [0.4.0] - 2026-10-18
### Added
- napalm-device-types 4.17's public contracts: `apply_updates()` passes the packages it
is given and `get_update_progress()` reports the upgrade's state and log;
`get_dns_forwards()`, `set_dns_forward()` and `remove_dns_forward()` for Unbound;
`get_ospf()`. Requires napalm-device-types 4.17.0.
## [0.3.0] - 2026-10-17
### Removed
- **Breaking:** `get_device_warnings()`. The pending updates are in
`get_available_updates()`.
## [0.2.0] - 2026-10-17
### Changed
- **Breaking:** `run_device_action` is removed. The SNMP agent is set up through
napalm-device-types 4.13's `configure_snmp_agent` with the caller's community,
contact, location and listen address, and the firewall opens UDP/161 only to the
allowed sources (#13).
+191
View File
@@ -0,0 +1,191 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship made available under
the License, as indicated by a copyright notice that is included in
or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other transformations
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean, as submitted to the Licensor for inclusion
in the Work by the copyright owner or by an individual or Legal Entity
authorized to submit on behalf of the copyright owner. For the purposes
of this definition, "submitted" means any form of electronic, verbal,
or written communication sent to the Licensor or its representatives,
including but not limited to communication on electronic mailing lists,
source code control systems, and issue tracking systems that are managed
by, or on behalf of, the Licensor for the purpose of discussing and
improving the Work, but excluding communication that is conspicuously
marked or designated in writing by the copyright owner as "Not a
Contribution."
"Contributor" shall mean Licensor and any Legal Entity on behalf of
whom a Contribution has been received by the Licensor and incorporated
within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by the combined Contribution(s) and the Work
to which such Contribution(s) was submitted. If You institute patent
litigation against any entity (including a cross-claim or counterclaim
in a lawsuit) alleging that the Work or any Contribution embodied
within the Work constitutes direct or contributory patent infringement,
then any patent licenses granted to You under this License for that
Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Derivative
Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, You must include a readable copy of the
attribution notices contained within such NOTICE file, in
at least one of the following places: within a NOTICE text file
distributed as part of the Derivative Works; within the Source
form or documentation, if provided along with the Derivative
Works; or, within a display generated by the Derivative Works,
if and wherever such third-party notices normally appear. The
contents of the NOTICE file are for informational purposes only
and do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own license statement for Your modifications and
may provide additional grant of rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of the Work,
and to permit persons to whom the Work is furnished to do so,
subject to the following conditions:
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any conditions of TITLE,
MERCHANTIBILITY, or FITNESS FOR A PARTICULAR PURPOSE.
See the License for the specific language governing permissions and
limitations under the License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or exemplary damages of any character arising as a
result of this License or out of the use or inability to use the
Work (even if such Contributor has been advised of the possibility
of such damages).
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may offer only
conditions consistent with this License, subject to the following
additional conditions:
(a) You may offer additional warranty, indemnity, or other liability
terms and conditions; and
(b) You may offer support for the Work in return for a fee.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format in question. It may also be
possible to make it available with a creative commons license.
Copyright 2026 Christian Manivong
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+18 -16
View File
@@ -14,8 +14,9 @@ NAPALM community driver for **OPNsense** firewalls (read-only, via REST API).
| Dependency | Minimum version | | Dependency | Minimum version |
|---|---| |---|---|
| Python | 3.9 | | Python | 3.10 |
| NAPALM | 4.0 | | NAPALM | 4.0 |
| napalm-device-types | 4.21.1 |
| requests | 2.28 | | requests | 2.28 |
## Installation ## Installation
@@ -27,7 +28,7 @@ pip install napalm-opnsense
Or from source: Or from source:
```bash ```bash
git clone https://github.com/napalm-automation-community/napalm-opnsense git clone https://git.netork.io/NAPALM/napalm-opnsense
cd napalm-opnsense cd napalm-opnsense
pip install -e . pip install -e .
``` ```
@@ -57,7 +58,7 @@ API keys**.
Pass the credentials via `optional_args`: Pass the credentials via `optional_args`:
```python ```python
optional_args={ optional_args = {
"api_key": "your-api-key", "api_key": "your-api-key",
"api_secret": "your-api-secret", "api_secret": "your-api-secret",
"verify": False, # set to a CA bundle path or True in production "verify": False, # set to a CA bundle path or True in production
@@ -71,9 +72,9 @@ arguments.
| Getter | Status | OPNsense API Endpoint | | Getter | Status | OPNsense API Endpoint |
|---|---|---| |---|---|---|
| `get_facts` | ✅ | `GET /api/core/system/status` | | `get_facts` | ✅ | `GET /api/diagnostics/system/system_information` (name, version) + `GET /api/core/system/status` (fallback on older versions) |
| `get_interfaces` | ✅ | `GET /api/interfaces/overview/export` | | `get_interfaces` | ✅ | `GET /api/interfaces/overview/export` |
| `get_interfaces_ip` | ✅ | `GET /api/interfaces/addresses/export` | | `get_interfaces_ip` | ✅ | `GET /api/interfaces/overview/export`; a CARP address only on the node that is MASTER for it |
| `get_interfaces_counters` | ✅ | `GET /api/diagnostics/interface/get_interface_statistics` | | `get_interfaces_counters` | ✅ | `GET /api/diagnostics/interface/get_interface_statistics` |
| `get_arp_table` | ✅ | `GET /api/diagnostics/interface/get_arp` | | `get_arp_table` | ✅ | `GET /api/diagnostics/interface/get_arp` |
| `get_ipv6_neighbors_table` | ✅ | `GET /api/diagnostics/interface/get_ndp` | | `get_ipv6_neighbors_table` | ✅ | `GET /api/diagnostics/interface/get_ndp` |
@@ -88,6 +89,8 @@ arguments.
| `get_vlans` | ✅ | `GET /api/interfaces/vlan_settings/search_item` | | `get_vlans` | ✅ | `GET /api/interfaces/vlan_settings/search_item` |
| `get_mac_address_table` | ❌ | Not applicable (firewall, no L2 switching) | | `get_mac_address_table` | ❌ | Not applicable (firewall, no L2 switching) |
| `ping` | ✅ | `POST /api/diagnostics/ping/set` + `start` + `search_jobs` + `stop`/`remove` | | `ping` | ✅ | `POST /api/diagnostics/ping/set` + `start` + `search_jobs` + `stop`/`remove` |
| `get_listening_sockets` | ✅ | `GET /api/diagnostics/interface/get_socket_statistics` + `/api/core/service/search` + `/api/wireguard/service/show` (WireGuard's kernel sockets by listen port) |
| `get_virtual_ips` | ✅ | `GET /api/interfaces/overview/export`: every address bound to a CARP VHID (CARP address and IP aliases on it), with this node's CARP state. `interfaces/vip_settings` is not read: it carries the CARP passwords |
| `ping_sweep` | ✅ ³ | same endpoints, one batch of parallel jobs at a time | | `ping_sweep` | ✅ ³ | same endpoints, one batch of parallel jobs at a time |
> ¹ Requires the `os-lldpd` plugin. Returns empty dict if the plugin is not installed. > ¹ Requires the `os-lldpd` plugin. Returns empty dict if the plugin is not installed.
@@ -171,9 +174,9 @@ d.open()
routes = json.dumps([{"network": "10.0.0.0/8", "gateway": "WAN_GW"}]) routes = json.dumps([{"network": "10.0.0.0/8", "gateway": "WAN_GW"}])
d.load_merge_candidate(config=routes) d.load_merge_candidate(config=routes)
print(d.compare_config()) # unified diff print(d.compare_config()) # unified diff
d.commit_config() # applies routes and calls reconfigure d.commit_config() # applies routes and calls reconfigure
d.rollback() # removes the routes just added d.rollback() # removes the routes just added
d.close() d.close()
``` ```
@@ -199,19 +202,18 @@ d.close()
## Development ## Development
The rules are in [STANDARDS.md](https://git.netork.io/NAPALM/driver-standard/src/branch/main/STANDARDS.md).
```bash ```bash
pip install -e ".[dev]" python -m venv .venv && .venv/bin/pip install -e ".[dev]"
pytest tests/ .venv/bin/pytest --cov
``` ```
## CI ## CI
This project includes a GitHub Actions workflow that: [.gitea/workflows/ci.yml](.gitea/workflows/ci.yml) calls the shared workflow of the
- Runs unit tests across Python 3.9–3.12 driver standard: ruff, mypy, the tests on Python 3.10–3.13 and at the lowest declared
- Builds sdist and wheel versions, bandit, pip-audit and a build check. A release tag publishes to PyPI.
- Uploads build artifacts
See [.github/workflows/ci.yml](.github/workflows/ci.yml).
## License ## License
+109
View File
@@ -0,0 +1,109 @@
"""Kea's HA peers and the firewall's configuration sync, as facts (netOrk #1125).
Two answers of the OPNsense API, read as they are:
* ``/api/kea/dhcpv4/get`` (its ``ha`` section) and ``/api/kea/dhcpv4/searchPeer``:
whether Kea's HA hook is on, which peer this firewall is, and each peer's name,
role and HA listener URL. Configuration, not state -- Kea's control agent, which
knows whether the pair is in hot standby right now, listens on localhost only.
* ``/api/core/hasync/get``: the address this firewall pushes its configuration
to (``synchronizetoip``) and the ticked sync items. An item is named by
OPNsense's sync section, not by the service it covers; :data:`SYNC_ITEM_SERVICES`
maps the ones that are a service to the name ``get_services`` reports.
"""
from __future__ import annotations
from typing import Any
from napalm_device_types.models import ConfigSyncDict, DhcpHaDict, DhcpHaPeerDict
#: Kea's DHCPv4 server as ``get_services`` names it.
KEA_SERVICE = "kea-dhcp"
#: Sync item -> the service it carries the configuration of, by the name
#: ``get_services`` reports. Items that are configuration only (aliases, rules,
#: NAT, users ...) have no service and are reported as unmapped.
SYNC_ITEM_SERVICES: dict[str, str] = {
"captiveportal": "captiveportal",
"cron": "cron",
"ddclient": "ddclient",
"dnsforwarder": "dnsmasq",
"dnsresolver": "unbound",
"hostwatch": "hostwatch",
"kea": KEA_SERVICE,
"mdns-repeater": "mdns-repeater",
"netsnmp": "snmpd",
"ntpd": "ntpd",
"qemuguestagent": "qemu-ga",
"quagga": "frr",
"radvd": "radvd",
"squid": "squid",
"ssh": "openssh",
"sysctl": "sysctl",
"syslog-ng": "syslog-ng",
"webgui": "webgui",
"wireguard": "wireguard",
"zabbixagent": "zabbix_agentd",
}
def _section(answer: Any, *path: str) -> dict:
node = answer
for key in path:
if not isinstance(node, dict) or not isinstance(node.get(key), dict):
raise ValueError(f"unexpected answer: no {'.'.join(path)}")
node = node[key]
return node
def dhcp_ha(settings: Any, peers: Any) -> DhcpHaDict:
"""Kea's HA configuration from the two answers.
``this_server_name`` is empty while left at its default, the hostname; the
effective name is then in its ``%`` twin.
:raises ValueError: when *settings* has no ``dhcpv4.ha`` section.
"""
ha = _section(settings, "dhcpv4", "ha")
result: DhcpHaDict = {
"service": KEA_SERVICE,
"enabled": str(ha.get("enabled", "0")) == "1",
"peers": [],
}
this_server = str(ha.get("this_server_name") or ha.get("%this_server_name") or "").strip()
if this_server:
result["this_server"] = this_server
rows = peers.get("rows") if isinstance(peers, dict) else None
for row in rows or []:
if not isinstance(row, dict) or not row.get("name"):
continue
peer: DhcpHaPeerDict = {"name": str(row["name"]), "role": str(row.get("role") or "")}
if row.get("url"):
peer["url"] = str(row["url"])
result["peers"].append(peer)
return result
def config_sync(answer: Any) -> ConfigSyncDict:
"""Where this firewall pushes its configuration, and which services' it pushes.
Ticked items without a target address push nothing, so they count for nothing.
:raises ValueError: when *answer* has no ``hasync`` section.
"""
section = _section(answer, "hasync")
peer = str(section.get("synchronizetoip") or "").strip() or None
if peer is None:
return {"peer": None, "services": [], "unmapped": []}
items = section.get("syncitems")
ticked = sorted(
name
for name, item in (items.items() if isinstance(items, dict) else [])
if isinstance(item, dict) and str(item.get("selected", "0")) == "1"
)
return {
"peer": peer,
"services": sorted({SYNC_ITEM_SERVICES[i] for i in ticked if i in SYNC_ITEM_SERVICES}),
"unmapped": [i for i in ticked if i not in SYNC_ITEM_SERVICES],
}
+130
View File
@@ -0,0 +1,130 @@
"""What listens on the firewall, and which OPNsense service it is. Pure: no I/O.
``/api/diagnostics/interface/get_socket_statistics`` is netstat's socket list
with sockstat's user, command and PID merged in, read by configd as root. The
contract this serves -- ``napalm_device_types.models.ListeningSocketsDict`` --
wants the sockets that listen: a TCP socket in LISTEN and a bound UDP socket,
both without a peer (``*:*``).
**Which service.** netOrk keeps a firewall's services under the names of its
service list (``/api/core/service/search``), so a socket's ``unit`` is one of
those names, or None. The statistics name the process, cut to ten characters
by FreeBSD (``mdns-repea``): a name the service list has wins, then one the
command starts with or -- cut short -- is the start of, then the few daemons
whose service is called otherwise (``sshd`` is ``openssh``, ``ospfd`` is
``frr``). The admin UI and the captive portal are both lighttpd; the portal's
runs as ``www``.
**WireGuard has no process.** Its sockets belong to the kernel (``??``) and are
told by the listen ports of the WireGuard interfaces.
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from napalm_device_types.models import ListeningSocketDict
#: FRR's daemons, all under the one service ``frr``.
_FRR_DAEMONS = "zebra mgmtd staticd ospfd ospf6d bgpd bfdd ripd ripngd isisd pimd watchfrr".split()
#: Daemons whose OPNsense service has another name, by the command as reported.
_SERVICE_OF: dict[str, str] = {
"sshd": "openssh",
"kea-ctrl-a": "kea-dhcp",
"kea-ctrl-agent": "kea-dhcp",
"charon": "strongswan",
**{daemon: "frr" for daemon in _FRR_DAEMONS},
}
#: FreeBSD cuts a command at this many characters in the socket list.
_COMMAND_LENGTH = 10
#: Below this length a service name is too short to match a command's start (``pf``).
_PREFIX_MIN = 4
_NOBODY = "??"
_PEERLESS = "*"
def _service_of(command: str, user: str, services: set[str]) -> str | None:
if command == "lighttpd":
name = "captiveportal" if user == "www" else "webgui"
return name if name in services else None
if command in services:
return command
alias = _SERVICE_OF.get(command)
if alias in services:
return alias
matches = [
name
for name in services
if (len(name) >= _PREFIX_MIN and command.startswith(name))
or (len(command) == _COMMAND_LENGTH and name.startswith(command))
]
return matches[0] if len(matches) == 1 else None
def _address(protocol: str, address: str) -> tuple:
"""``(address, interface)``: ``*`` is every address of the socket's family."""
if address == "*":
# A reported wildcard address (data from sockstat), not a bind.
return ("::" if protocol.endswith("6") else "0.0.0.0"), None # nosec B104
host, _, zone = address.partition("%")
return host, zone or None
def _socket(
entry: dict[str, Any], services: set[str], wireguard_ports: set[int]
) -> ListeningSocketDict | None:
protocol = str(entry.get("protocol") or "")
proto = protocol[:3]
local, remote = entry.get("local") or {}, entry.get("remote") or {}
port = str(local.get("port") or "")
if proto not in ("tcp", "udp") or not port.isdigit():
return None
if remote.get("address") != _PEERLESS or remote.get("port") != _PEERLESS:
return None
command, pid = str(entry.get("command") or _NOBODY), str(entry.get("pid") or _NOBODY)
process = None if command == _NOBODY else command
if process is not None:
unit = _service_of(process, str(entry.get("user") or ""), services)
elif proto == "udp" and int(port) in wireguard_ports and "wireguard" in services:
unit = "wireguard"
else:
unit = None
address, interface = _address(protocol, str(local.get("address") or ""))
return {
"proto": proto,
"address": address,
"port": int(port),
"interface": interface,
"process": process,
"pid": int(pid) if pid.isdigit() else None,
"unit": unit,
"container_id": None,
}
def wireguard_listen_ports(show: dict[str, Any]) -> set[int]:
"""The listen ports of the WireGuard interfaces in ``/api/wireguard/service/show``."""
ports: set[int] = set()
for row in show.get("rows") or []:
port = str(row.get("listen-port") or "")
if row.get("type") == "interface" and port.isdigit():
ports.add(int(port))
return ports
def parse_socket_statistics(
statistics: dict[str, Any], services: Iterable[str], wireguard_ports: set[int]
) -> list[ListeningSocketDict]:
"""The listening sockets of ``get_socket_statistics``, sorted by protocol,
port and address. Every section is read: the UNIX sockets have no ``local``."""
names = set(services)
sockets: list[ListeningSocketDict] = []
for section in (statistics.get("statistics") or {}).values():
for entry in (section or {}).values() if isinstance(section, dict) else ():
socket = _socket(entry, names, wireguard_ports) if isinstance(entry, dict) else None
if socket is not None:
sockets.append(socket)
return sorted(
sockets, key=lambda s: (s["proto"], s["port"], s["address"], s["interface"] or "")
)
File diff suppressed because it is too large. Load diff
+43
View File
@@ -0,0 +1,43 @@
"""FRR's OSPF state in neutral terms (napalm-device-types ``OspfMixin``).
Reads the os-frr plugin's diagnostics (``/api/quagga/diagnostics/...``); the
field names are FRR's. Whether an interface without neighbours or without
authentication is a problem is the caller's to decide.
"""
from __future__ import annotations
from typing import Any
from napalm_device_types import OspfMixin
_DIAG = "/api/quagga/diagnostics"
def _interface(ifc: dict[str, Any]) -> dict[str, Any]:
return {
"address": ifc.get("ipAddress") or "",
"prefix_length": int(ifc.get("ipAddressPrefixlen") or 0),
"area": ifc.get("area") or "",
"network_type": ifc.get("networkType") or "",
"authentication": ifc.get("authentication") or "",
"neighbors": int(ifc.get("nbrCount") or 0),
"neighbors_adjacent": int(ifc.get("nbrAdjacentCount") or 0),
"cost": int(ifc.get("cost") or 0),
}
class OPNsenseOspfMixin(OspfMixin):
"""Needs the driver's ``_get``."""
def get_ospf(self) -> dict[str, Any]:
"""Router ID, areas and OSPF per interface; empty when OSPF does not run."""
overview = self._get(f"{_DIAG}/ospfoverview").get("response") or {} # type: ignore[attr-defined]
interfaces = (self._get(f"{_DIAG}/ospfinterface").get("response") or {}).get(
"interfaces"
) or {} # type: ignore[attr-defined]
return {
"router_id": overview.get("routerId") or None,
"areas": list((overview.get("areas") or {}).keys()),
"interfaces": {name: _interface(ifc) for name, ifc in interfaces.items()},
}
+8 -10
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
# You may obtain a copy of the License at # You may obtain a copy of the License at
@@ -45,8 +44,9 @@ from __future__ import annotations
import logging import logging
import time import time
from collections.abc import Callable, Iterable
from ipaddress import ip_address from ipaddress import ip_address
from typing import Any, Callable, Iterable, Optional from typing import Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -78,7 +78,7 @@ class OPNsensePingMixin:
PING_SWEEP_MAX_TARGETS: int = 512 PING_SWEEP_MAX_TARGETS: int = 512
# Provided by the driver this mixin is mixed into. # Provided by the driver this mixin is mixed into.
_ping_model_path_cache: Optional[tuple[str, ...]] = None _ping_model_path_cache: tuple[str, ...] | None = None
# ── NAPALM ─────────────────────────────────────────────────────────────── # ── NAPALM ───────────────────────────────────────────────────────────────
@@ -119,9 +119,9 @@ class OPNsensePingMixin:
*, *,
count: int = 1, count: int = 1,
timeout: int = 1, timeout: int = 1,
max_targets: Optional[int] = None, max_targets: int | None = None,
on_progress: Optional[Callable[[int, int], None]] = None, on_progress: Callable[[int, int], None] | None = None,
should_stop: Optional[Callable[[], bool]] = None, should_stop: Callable[[], bool] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Ping many destinations, one batch of parallel firewall jobs at a time. """Ping many destinations, one batch of parallel firewall jobs at a time.
@@ -245,9 +245,7 @@ class OPNsensePingMixin:
if attempt: if attempt:
time.sleep(_POLL_INTERVAL) time.sleep(_POLL_INTERVAL)
rows = { rows = {
job_id: row job_id: row for job_id, row in self._read_ping_jobs().items() if job_id in wanted
for job_id, row in self._read_ping_jobs().items()
if job_id in wanted
} }
if wanted and all( if wanted and all(
_as_int(rows.get(job_id, {}).get("send")) >= count for job_id in wanted _as_int(rows.get(job_id, {}).get("send")) >= count for job_id in wanted
@@ -325,7 +323,7 @@ class OPNsensePingMixin:
@staticmethod @staticmethod
def _row_to_napalm_reply( def _row_to_napalm_reply(
destination: str, row: Optional[dict[str, Any]], *, count: int destination: str, row: dict[str, Any] | None, *, count: int
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Turn one ``search_jobs`` row into NAPALM's ping reply shape. """Turn one ``search_jobs`` row into NAPALM's ping reply shape.
+16 -12
View File
@@ -18,11 +18,12 @@ from __future__ import annotations
import ipaddress import ipaddress
import socket import socket
from typing import Any, Dict, Iterable, List, Optional, Tuple from collections.abc import Iterable
from typing import Any
#: Port names OPNsense accepts in a rule. ``socket.getservbyname`` knows them #: Port names OPNsense accepts in a rule. ``socket.getservbyname`` knows them
#: too, but only where ``/etc/services`` exists -- a slim container has none. #: too, but only where ``/etc/services`` exists -- a slim container has none.
WELL_KNOWN_PORTS: Dict[str, int] = { WELL_KNOWN_PORTS: dict[str, int] = {
"ftp": 21, "ftp": 21,
"ssh": 22, "ssh": 22,
"telnet": 23, "telnet": 23,
@@ -49,7 +50,7 @@ WELL_KNOWN_PORTS: Dict[str, int] = {
#: Alias types whose entries can be addresses. #: Alias types whose entries can be addresses.
_ADDRESS_ALIASES = ("host", "network") _ADDRESS_ALIASES = ("host", "network")
AliasIndex = Dict[str, Tuple[str, List[str]]] AliasIndex = dict[str, tuple[str, list[str]]]
def wan_interfaces(overview: Any) -> set: def wan_interfaces(overview: Any) -> set:
@@ -61,20 +62,23 @@ def wan_interfaces(overview: Any) -> set:
def alias_index(rows: Iterable[dict]) -> AliasIndex: def alias_index(rows: Iterable[dict]) -> AliasIndex:
"""``name -> (type, entries)`` from the alias list.""" """``name -> (type, entries)`` from the alias list."""
return { return {
r["name"]: (r.get("type", ""), [e.strip() for e in str(r.get("content", "")).splitlines() if e.strip()]) r["name"]: (
r.get("type", ""),
[e.strip() for e in str(r.get("content", "")).splitlines() if e.strip()],
)
for r in rows for r in rows
if r.get("name") if r.get("name")
} }
def _as_address(value: str) -> Optional[str]: def _as_address(value: str) -> str | None:
try: try:
return str(ipaddress.ip_address(value)) return str(ipaddress.ip_address(value))
except ValueError: except ValueError:
return None return None
def _addresses(target: str, aliases: AliasIndex) -> List[str]: def _addresses(target: str, aliases: AliasIndex) -> list[str]:
"""The addresses a rule sends to: a literal, or a host/network alias's. """The addresses a rule sends to: a literal, or a host/network alias's.
Anything else -- an interface address, a name resolved by DNS -- gives no Anything else -- an interface address, a name resolved by DNS -- gives no
@@ -89,7 +93,7 @@ def _addresses(target: str, aliases: AliasIndex) -> List[str]:
return [a for a in (_as_address(e) for e in entries) if a] return [a for a in (_as_address(e) for e in entries) if a]
def _port(value: Any, protocol: str, aliases: AliasIndex) -> Optional[int]: def _port(value: Any, protocol: str, aliases: AliasIndex) -> int | None:
"""A rule's port as a number: literal, start of a range, alias or name. """A rule's port as a number: literal, start of a range, alias or name.
No port at all means every port, which the contract writes as 0. No port at all means every port, which the contract writes as 0.
@@ -115,7 +119,7 @@ def _faces_the_wan(rule: dict, wan: set) -> bool:
return bool(set(str(rule.get("interface", "")).split(",")) & wan) return bool(set(str(rule.get("interface", "")).split(",")) & wan)
def _remote_host(rule: dict) -> Optional[str]: def _remote_host(rule: dict) -> str | None:
"""A source restriction; an inverted one ("all but X") restricts nothing.""" """A source restriction; an inverted one ("all but X") restricts nothing."""
source = str(rule.get("source.network") or "").strip() source = str(rule.get("source.network") or "").strip()
if source in ("", "any") or rule.get("source.not") == "1": if source in ("", "any") or rule.get("source.not") == "1":
@@ -123,11 +127,11 @@ def _remote_host(rule: dict) -> Optional[str]:
return source return source
def _forwards_of(rule: dict, aliases: AliasIndex) -> List[dict]: def _forwards_of(rule: dict, aliases: AliasIndex) -> list[dict]:
protocols = [p.upper() for p in str(rule.get("protocol") or "any").split("/") if p] protocols = [p.upper() for p in str(rule.get("protocol") or "any").split("/") if p]
target = str(rule.get("target", "")).strip() target = str(rule.get("target", "")).strip()
remote = _remote_host(rule) remote = _remote_host(rule)
result: List[dict] = [] result: list[dict] = []
for protocol in protocols: for protocol in protocols:
external = _port(rule.get("destination.port", ""), protocol.lower(), aliases) external = _port(rule.get("destination.port", ""), protocol.lower(), aliases)
if external is None: if external is None:
@@ -150,9 +154,9 @@ def _forwards_of(rule: dict, aliases: AliasIndex) -> List[dict]:
return result return result
def port_forwards(rules: Iterable[dict], wan: set, aliases: AliasIndex) -> List[dict]: def port_forwards(rules: Iterable[dict], wan: set, aliases: AliasIndex) -> list[dict]:
"""The destination-NAT rules on a WAN interface, as ``PortForwardDict`` entries.""" """The destination-NAT rules on a WAN interface, as ``PortForwardDict`` entries."""
result: List[dict] = [] result: list[dict] = []
for rule in rules: for rule in rules:
if _faces_the_wan(rule, wan): if _faces_the_wan(rule, wan):
result.extend(_forwards_of(rule, aliases)) result.extend(_forwards_of(rule, aliases))
View File
Whitespace-only changes.
+75
View File
@@ -0,0 +1,75 @@
"""Unbound's domain forwards (napalm-device-types ``DnsForwardMixin``).
OPNsense keeps forwards and DNS-over-TLS servers in one model (``dot``); a
forward is a row of type ``forward``. Every change is applied with a
reconfigure before the method returns.
"""
from __future__ import annotations
from typing import Any
from napalm_device_types import DnsForwardMixin
_SETTINGS = "/api/unbound/settings"
_RECONFIGURE = "/api/unbound/service/reconfigure"
def _domain(name: str) -> str:
return name.strip().rstrip(".")
class OPNsenseResolverMixin(DnsForwardMixin):
"""Needs the driver's ``_post``."""
def _forward_rows(self) -> list[dict[str, Any]]:
rows = self._post(f"{_SETTINGS}/searchForward", {}).get("rows") or [] # type: ignore[attr-defined]
return [r for r in rows if r.get("type", "forward") == "forward"]
def _saved(self, result: dict[str, Any], what: str) -> None:
if result.get("result") not in ("saved", "deleted", None):
raise RuntimeError(f"Unbound refused {what}: {result.get('validations') or result}")
def get_dns_forwards(self) -> list[dict[str, Any]]:
"""Every forward Unbound has, enabled or not; DNS-over-TLS servers are not forwards."""
return [
{
"domain": _domain(r.get("domain", "")),
"server": r.get("server", ""),
"port": int(r.get("port") or 53),
"enabled": str(r.get("enabled", "1")) == "1",
}
for r in self._forward_rows()
]
def set_dns_forward(self, domain: str, server: str, port: int = 53) -> None:
"""Forward *domain* to *server*; an existing forward of the domain is changed."""
name = _domain(domain)
row = {
"enabled": "1",
"type": "forward",
"domain": name,
"server": server,
"port": str(port),
}
known = [r for r in self._forward_rows() if _domain(r.get("domain", "")) == name]
if known:
result = self._post(f"{_SETTINGS}/setForward/{known[0]['uuid']}", {"dot": row}) # type: ignore[attr-defined]
else:
result = self._post(f"{_SETTINGS}/addForward", {"dot": row}) # type: ignore[attr-defined]
self._saved(result or {}, f"the forward of {name}")
self._post(_RECONFIGURE) # type: ignore[attr-defined]
def remove_dns_forward(self, domain: str) -> None:
"""Delete every forward of *domain*; nothing to do when there is none."""
name = _domain(domain)
uuids = [
r["uuid"]
for r in self._forward_rows()
if _domain(r.get("domain", "")) == name and r.get("uuid")
]
if not uuids:
return
for uuid in uuids:
self._post(f"{_SETTINGS}/delForward/{uuid}") # type: ignore[attr-defined]
self._post(_RECONFIGURE) # type: ignore[attr-defined]
+124
View File
@@ -0,0 +1,124 @@
"""The firewall's CARP addresses, read from its interface overview (netOrk #1121).
``/api/interfaces/overview/export`` lists every address of an interface; one bound
to a CARP VHID carries ``vhid`` and this node's CARP ``status``. That covers the
CARP address itself and an IP alias bound to its VHID alike -- both float with the
VHID's MASTER. An address without a VHID stays on this node and is no virtual IP.
``interfaces/vip_settings/search_item`` would list the same addresses, but with
each VHID's CARP password in it. The overview has none, so a secret never reaches
the caller.
What a state means -- MASTER holds, BACKUP stands by -- is CARP protocol
knowledge, so it lives here with the driver that speaks CARP
(:func:`carp_virtual_ip`), not in napalm-device-types, which carries only the
shape of the reading.
"""
from __future__ import annotations
import ipaddress
from typing import Any
from napalm_device_types.models import VirtualIpDict
#: CARP's state of a VHID on one member -> (running, holds).
_CARP_STATES: dict[str, tuple[bool, bool]] = {
"MASTER": (True, True),
"BACKUP": (True, False),
"INIT": (False, False),
"DISABLED": (False, False),
}
def _address(text: str) -> tuple[str, int | None] | None:
"""``"10.0.0.1/24"`` -> ``("10.0.0.1", 24)``; None for what is no address."""
host, slash, prefix = text.partition("/")
try:
address = ipaddress.ip_address(host)
except ValueError:
return None
if not slash:
return str(address), None
if not prefix.isdigit() or int(prefix) > address.max_prefixlen:
return None
return str(address), int(prefix)
def carp_virtual_ip(
*,
address: str,
prefix_length: int | None,
vhid: object,
state: str | None,
instance: str,
interface: str | None,
) -> VirtualIpDict | None:
"""One address bound to a CARP VHID, as a virtual IP; None for what is no address.
Who holds it comes from CARP's *state* on this member, not from its
interfaces: MASTER holds it, BACKUP stands by, INIT and DISABLED neither run
nor hold. A state this does not know says nothing (``running`` and ``holds``
None), and the caller falls back on the interfaces. *address* may carry its
prefix (``"10.0.0.1/24"``), which then wins over *prefix_length*.
"""
parsed = _address(address.strip()) if address else None
if parsed is None:
return None
normalised, written = parsed
prefix = written if written is not None else prefix_length
if prefix is not None and not 0 <= prefix <= ipaddress.ip_address(normalised).max_prefixlen:
prefix = None
vhid_text = str(vhid).strip() if vhid is not None else ""
running, holds = _CARP_STATES.get((state or "").strip().upper(), (None, None))
return {
"mechanism": "carp",
"instance": instance or f"vhid {vhid_text}",
"address": normalised,
"prefix_length": prefix,
"interface": interface,
"group_key": f"vhid:{vhid_text}" if vhid_text.isdigit() else None,
"running": running,
"holds": holds,
}
def _interfaces(overview: Any) -> list[Any]:
"""The interface list, whether the answer is the list or wraps it."""
if isinstance(overview, dict):
return overview.get("interfaces") or []
if isinstance(overview, list):
return overview
raise ValueError(f"unexpected interface overview: {type(overview).__name__}")
def carp_addresses(overview: Any) -> list[VirtualIpDict]:
"""Every address bound to a CARP VHID in *overview*, sorted by address.
:raises ValueError: when the answer is neither the interface list nor wraps it.
"""
entries: list[VirtualIpDict] = []
for interface in _interfaces(overview):
if not isinstance(interface, dict):
continue
identifier = interface.get("identifier") or ""
for family in ("ipv4", "ipv6"):
for address in interface.get(family) or []:
if not isinstance(address, dict) or not address.get("vhid"):
continue
entry = carp_virtual_ip(
address=str(address.get("ipaddr") or ""),
prefix_length=None,
vhid=address.get("vhid"),
state=address.get("status"),
instance=interface.get("description") or identifier,
interface=interface.get("device") or identifier or None,
)
if entry is not None:
entries.append(entry)
return sorted(entries, key=_order)
def _order(entry: VirtualIpDict) -> tuple[int, int]:
address = ipaddress.ip_address(entry["address"])
return address.version, int(address)
+53 -11
View File
@@ -1,54 +1,96 @@
[build-system] [build-system]
requires = ["setuptools>=68", "wheel"] requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "napalm-opnsense" name = "napalm-opnsense"
version = "0.1.0" version = "0.5.0"
description = "NAPALM driver for OPNsense (read-only via REST API)." description = "NAPALM driver for OPNsense (read-only via REST API)."
readme = "README.md" readme = "README.md"
license = { text = "Apache-2.0" } license = "Apache-2.0"
requires-python = ">=3.10" requires-python = ">=3.10"
authors = [ authors = [
{ name = "Christian Manivong" }, { name = "Christian Manivong" },
] ]
classifiers = [ classifiers = [
"Topic :: Utilities", "Topic :: Utilities",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Operating System :: POSIX :: Linux", "Operating System :: POSIX :: Linux",
"Operating System :: MacOS", "Operating System :: MacOS",
"Typing :: Typed",
] ]
dependencies = [ dependencies = [
"napalm>=4.0.0", "napalm>=4.0.0",
"napalm_device_types>=0.2.0", "napalm-device-types>=4.21.1",
"requests>=2.28.0", "requests>=2.28.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest", "pytest>=8",
"pytest-cov", "pytest-cov>=5",
"black", "mypy>=1.11",
"ruff", "ruff>=0.6",
] ]
[project.entry-points."napalm.drivers"] [project.entry-points."napalm.drivers"]
opnsense = "napalm_opnsense.opnsense:OPNsenseDriver" opnsense = "napalm_opnsense.opnsense:OPNsenseDriver"
[project.urls] [project.urls]
Repository = "https://github.com/napalm-automation-community/napalm-opnsense" Repository = "https://git.netork.io/NAPALM/napalm-opnsense"
Issues = "https://git.netork.io/NAPALM/napalm-opnsense/issues"
Changelog = "https://git.netork.io/NAPALM/napalm-opnsense/src/branch/main/CHANGELOG.md"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]
include = ["napalm_opnsense*"] include = ["napalm_opnsense*"]
[tool.setuptools.package-data]
napalm_opnsense = ["py.typed"]
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
target-version = "py39" target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[tool.ruff.lint.per-file-ignores]
# Recorded device output keeps its lines.
"tests/**" = ["E501"]
[tool.mypy]
python_version = "3.10"
strict = true
# napalm itself is untyped, so every driver subclasses Any.
disallow_subclassing_any = false
[[tool.mypy.overrides]]
module = ["napalm.*", "requests.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
# Not yet under strict mypy (quality baseline); may only shrink.
module = [
"napalm_opnsense.ha",
"napalm_opnsense.listening",
"napalm_opnsense.opnsense",
"napalm_opnsense.ospf_mixin",
"napalm_opnsense.ping_mixin",
"napalm_opnsense.port_forwards",
"napalm_opnsense.resolver_mixin",
]
ignore_errors = true
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
[tool.coverage.run]
source = ["napalm_opnsense"]
[tool.coverage.report]
fail_under = 79
+65
View File
@@ -0,0 +1,65 @@
{
"coverage_floor": 79.0,
"function_complexity": {
"napalm_opnsense/ha.py::config_sync": 12,
"napalm_opnsense/ha.py::dhcp_ha": 11,
"napalm_opnsense/listening.py::_service_of": 12,
"napalm_opnsense/listening.py::_socket": 19,
"napalm_opnsense/opnsense.py::OPNsenseDriver._allow_snmp_sources": 11,
"napalm_opnsense/opnsense.py::OPNsenseDriver._get_unbound_host_overrides": 17,
"napalm_opnsense/opnsense.py::OPNsenseDriver._kea_read_list": 11,
"napalm_opnsense/opnsense.py::OPNsenseDriver.apply_dhcp_subnet": 21,
"napalm_opnsense/opnsense.py::OPNsenseDriver.apply_firewall_rule": 11,
"napalm_opnsense/opnsense.py::OPNsenseDriver.create_dhcp_reservation": 16,
"napalm_opnsense/opnsense.py::OPNsenseDriver.delete_dhcp_reservation_and_lease": 14,
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_bgp_neighbors": 16,
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_dhcp_leases": 15,
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_dhcp_leases._parse_kea": 14,
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_dhcp_reservations": 15,
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_dhcp_subnets": 17,
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_facts": 12,
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_firewall_rules": 30,
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_interfaces": 13,
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_interfaces_ip": 34,
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_lldp_neighbors_detail": 12,
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_networks": 25,
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_route_to": 23,
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_vpn_tunnels": 60,
"napalm_opnsense/opnsense.py::OPNsenseDriver.load_merge_candidate": 13,
"napalm_opnsense/opnsense.py::OPNsenseDriver.sync_dns_zone": 20,
"napalm_opnsense/port_forwards.py::_forwards_of": 12,
"napalm_opnsense/virtual_ips.py::carp_addresses": 14
},
"function_lines": {
"napalm_opnsense/opnsense.py::OPNsenseDriver._get_unbound_host_overrides": 51,
"napalm_opnsense/opnsense.py::OPNsenseDriver._regenerate_web_cert": 51,
"napalm_opnsense/opnsense.py::OPNsenseDriver.apply_dhcp_subnet": 60,
"napalm_opnsense/opnsense.py::OPNsenseDriver.create_dhcp_reservation": 85,
"napalm_opnsense/opnsense.py::OPNsenseDriver.delete_dhcp_reservation_and_lease": 79,
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_bgp_neighbors": 86,
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_dhcp_leases": 106,
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_dhcp_subnets": 60,
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_firewall_rules": 84,
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_interfaces_ip": 68,
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_networks": 85,
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_route_to": 85,
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_vpn_tunnels": 189,
"napalm_opnsense/opnsense.py::OPNsenseDriver.load_merge_candidate": 56,
"napalm_opnsense/opnsense.py::OPNsenseDriver.sync_dns_zone": 112
},
"mypy_relaxed": [
"napalm_opnsense.ha",
"napalm_opnsense.listening",
"napalm_opnsense.opnsense",
"napalm_opnsense.ospf_mixin",
"napalm_opnsense.ping_mixin",
"napalm_opnsense.port_forwards",
"napalm_opnsense.resolver_mixin"
],
"python_file_lines": {
"napalm_opnsense/opnsense.py": 3125
},
"untyped_public": [
"napalm_opnsense/opnsense.py::OPNsenseDriver.get_snmp_config"
]
}
+418
View File
@@ -0,0 +1,418 @@
{
"gw-01": {
"kea_dhcpv4_get": {
"dhcpv4": {
"ha": {
"enabled": "1",
"this_server_name": "",
"max_unacked_clients": "2",
"%this_server_name": "gw"
}
}
},
"kea_dhcpv4_searchPeer": {
"rows": [
{
"uuid": "574e7b41-a5c7-4682-bc63-f4a99e07f262",
"name": "gw",
"role": "primary",
"%role": "Primary",
"url": "http://10.5.100.2:8001/"
},
{
"uuid": "d6a4e812-4bc8-4713-bebb-b330ea65f587",
"name": "gw-02",
"role": "standby",
"%role": "Standby",
"url": "http://10.5.100.3:8001/"
}
],
"rowCount": 2,
"total": 2,
"current": 1
},
"core_hasync_get": {
"hasync": {
"synchronizetoip": "10.5.100.3",
"syncitems": {
"aliases": {
"value": "Aliases",
"selected": 1
},
"authservers": {
"value": "Authentication Servers",
"selected": 1
},
"captiveportal": {
"value": "Captive Portal",
"selected": 1
},
"certs": {
"value": "Certificates and Authorities",
"selected": 1
},
"cron": {
"value": "Cron",
"selected": 0
},
"ddclient": {
"value": "ddclient",
"selected": 1
},
"dhcrelay": {
"value": "DHCRelay",
"selected": 0
},
"dnsforwarder": {
"value": "Dnsmasq DNS/DHCP",
"selected": 0
},
"categories": {
"value": "Firewall Categories",
"selected": 1
},
"ifgroups": {
"value": "Firewall Groups",
"selected": 1
},
"lvtemplate": {
"value": "Firewall Log Templates",
"selected": 1
},
"rules": {
"value": "Firewall Rules",
"selected": 1
},
"schedules": {
"value": "Firewall Schedules",
"selected": 1
},
"freeradius": {
"value": "Freeradius",
"selected": 0
},
"quagga": {
"value": "FRR",
"selected": 0
},
"hostwatch": {
"value": "Host discovery service",
"selected": 0
},
"suricata": {
"value": "Intrusion Detection",
"selected": 0
},
"ipsec": {
"value": "IPsec",
"selected": 0
},
"kea": {
"value": "Kea DHCP",
"selected": 1
},
"mdns-repeater": {
"value": "mDNS Repeater",
"selected": 0
},
"monit": {
"value": "Monit System Monitoring",
"selected": 0
},
"nat": {
"value": "NAT",
"selected": 1
},
"syslog": {
"value": "Netflow / Insight",
"selected": 0
},
"netsnmp": {
"value": "Netsnmp",
"selected": 0
},
"ntpd": {
"value": "Network Time",
"selected": 1
},
"opendns": {
"value": "OpenDNS",
"selected": 0
},
"ssh": {
"value": "OpenSSH",
"selected": 1
},
"openvpn": {
"value": "OpenVPN",
"selected": 0
},
"qemuguestagent": {
"value": "QEMU Guest Agent",
"selected": 1
},
"radvd": {
"value": "Router Advertisements",
"selected": 0
},
"shaper": {
"value": "Shaper",
"selected": 0
},
"squid": {
"value": "Squid Web Proxy",
"selected": 0
},
"staticroutes": {
"value": "Static Routes",
"selected": 1
},
"syslog-ng": {
"value": "System logging",
"selected": 1
},
"sysctl": {
"value": "System Tunables",
"selected": 1
},
"dnsresolver": {
"value": "Unbound DNS",
"selected": 1
},
"users": {
"value": "Users and Groups",
"selected": 1
},
"virtualip": {
"value": "Virtual IPs",
"selected": 1
},
"wol": {
"value": "Wake on LAN",
"selected": 0
},
"webgui": {
"value": "Web GUI",
"selected": 1
},
"wireguard": {
"value": "WireGuard",
"selected": 1
},
"zabbixagent": {
"value": "Zabbix monitoring agent",
"selected": 0
}
}
}
}
},
"gw-02": {
"kea_dhcpv4_get": {
"dhcpv4": {
"ha": {
"enabled": "1",
"this_server_name": "",
"max_unacked_clients": "2",
"%this_server_name": "gw-02"
}
}
},
"kea_dhcpv4_searchPeer": {
"rows": [
{
"uuid": "574e7b41-a5c7-4682-bc63-f4a99e07f262",
"name": "gw",
"role": "primary",
"%role": "Primary",
"url": "http://10.5.100.2:8001/"
},
{
"uuid": "d6a4e812-4bc8-4713-bebb-b330ea65f587",
"name": "gw-02",
"role": "standby",
"%role": "Standby",
"url": "http://10.5.100.3:8001/"
}
],
"rowCount": 2,
"total": 2,
"current": 1
},
"core_hasync_get": {
"hasync": {
"synchronizetoip": "",
"syncitems": {
"aliases": {
"value": "Aliases",
"selected": 0
},
"authservers": {
"value": "Authentication Servers",
"selected": 0
},
"captiveportal": {
"value": "Captive Portal",
"selected": 0
},
"certs": {
"value": "Certificates and Authorities",
"selected": 0
},
"cron": {
"value": "Cron",
"selected": 0
},
"ddclient": {
"value": "ddclient",
"selected": 0
},
"dhcrelay": {
"value": "DHCRelay",
"selected": 0
},
"dnsforwarder": {
"value": "Dnsmasq DNS/DHCP",
"selected": 0
},
"categories": {
"value": "Firewall Categories",
"selected": 0
},
"ifgroups": {
"value": "Firewall Groups",
"selected": 0
},
"lvtemplate": {
"value": "Firewall Log Templates",
"selected": 0
},
"rules": {
"value": "Firewall Rules",
"selected": 0
},
"schedules": {
"value": "Firewall Schedules",
"selected": 0
},
"freeradius": {
"value": "Freeradius",
"selected": 0
},
"quagga": {
"value": "FRR",
"selected": 0
},
"hostwatch": {
"value": "Host discovery service",
"selected": 0
},
"suricata": {
"value": "Intrusion Detection",
"selected": 0
},
"ipsec": {
"value": "IPsec",
"selected": 0
},
"kea": {
"value": "Kea DHCP",
"selected": 0
},
"mdns-repeater": {
"value": "mDNS Repeater",
"selected": 0
},
"monit": {
"value": "Monit System Monitoring",
"selected": 0
},
"nat": {
"value": "NAT",
"selected": 0
},
"syslog": {
"value": "Netflow / Insight",
"selected": 0
},
"netsnmp": {
"value": "Netsnmp",
"selected": 0
},
"ntpd": {
"value": "Network Time",
"selected": 0
},
"opendns": {
"value": "OpenDNS",
"selected": 0
},
"ssh": {
"value": "OpenSSH",
"selected": 0
},
"openvpn": {
"value": "OpenVPN",
"selected": 0
},
"qemuguestagent": {
"value": "QEMU Guest Agent",
"selected": 0
},
"radvd": {
"value": "Router Advertisements",
"selected": 0
},
"shaper": {
"value": "Shaper",
"selected": 0
},
"squid": {
"value": "Squid Web Proxy",
"selected": 0
},
"staticroutes": {
"value": "Static Routes",
"selected": 0
},
"syslog-ng": {
"value": "System logging",
"selected": 0
},
"sysctl": {
"value": "System Tunables",
"selected": 0
},
"dnsresolver": {
"value": "Unbound DNS",
"selected": 0
},
"users": {
"value": "Users and Groups",
"selected": 0
},
"virtualip": {
"value": "Virtual IPs",
"selected": 0
},
"wol": {
"value": "Wake on LAN",
"selected": 0
},
"webgui": {
"value": "Web GUI",
"selected": 0
},
"wireguard": {
"value": "WireGuard",
"selected": 0
},
"zabbixagent": {
"value": "Zabbix monitoring agent",
"selected": 0
}
}
}
}
}
}
@@ -0,0 +1,62 @@
[
{
"identifier": "lan",
"device": "em0",
"description": "MGMT",
"addr4": "192.0.2.2/24",
"ipv4": [
{"ipaddr": "192.0.2.2/24"},
{"ipaddr": "192.0.2.1/24", "vhid": "108", "status": "MASTER", "advbase": "1", "advskew": "0", "peer": "224.0.0.18", "peer6": "ff02::12"}
],
"ipv6": [
{"ipaddr": "fe80::1:2/64"},
{"ipaddr": "2001:db8:8::1/64", "vhid": "108", "status": "MASTER", "advbase": "1", "advskew": "0", "peer": "224.0.0.18", "peer6": "ff02::12"}
]
},
{
"identifier": "opt2",
"device": "vlan0040",
"description": "SERVER",
"addr4": "198.51.100.2/24",
"ipv4": [
{"ipaddr": "198.51.100.2/24"},
{"ipaddr": "198.51.100.140/32", "vhid": "140", "status": "MASTER", "advbase": "1", "advskew": "0", "peer": "224.0.0.18", "peer6": "ff02::12"},
{"ipaddr": "198.51.100.1/24", "vhid": "140", "status": "MASTER", "advbase": "1", "advskew": "0", "peer": "224.0.0.18", "peer6": "ff02::12"}
]
},
{
"identifier": "opt9",
"device": "vlan0011",
"description": "",
"addr4": "203.0.113.2/24",
"ipv4": [
{"ipaddr": "203.0.113.2/24"},
{"ipaddr": "203.0.113.1/24", "vhid": "111", "status": "INIT", "advbase": "1", "advskew": "0", "peer": "224.0.0.18", "peer6": "ff02::12"}
]
},
{
"identifier": "opt3",
"device": "vlan0020",
"description": "IOT",
"addr4": "192.0.2.130/25",
"ipv4": [
{"ipaddr": "192.0.2.130/25"},
{"ipaddr": "192.0.2.140/32"}
]
},
{
"identifier": "lo0",
"device": "lo0",
"description": "Loopback",
"addr4": "127.0.0.1/8",
"ipv4": [{"ipaddr": "127.0.0.1/8"}],
"ipv6": [{"ipaddr": "::1/128"}, {"ipaddr": "fe80::1/64"}]
},
{
"identifier": "wg1",
"device": "wg1",
"description": "WireGuard",
"addr4": "",
"ipv4": null
}
]
+935
View File
@@ -0,0 +1,935 @@
{
"statistics": {
"Active Internet connections": {
"tcp4/[192.0.2.1:443-192.0.2.50:38544]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "192.0.2.1",
"port": "443",
"0": "192.0.2.1:443"
},
"remote": {
"address": "192.0.2.50",
"port": "38544",
"0": "192.0.2.50:38544"
},
"receive-high-water": 65700,
"send-high-water": 65700,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 525600,
"send-mbuf-bytes-max": 525600,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "7199.77",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "0.02",
"flow-id": "f6ae054e",
"flow-type": 130,
"user": "root",
"command": "lighttpd",
"pid": "46564",
"fd": "19",
"proto": "tcp4"
},
"tcp4/[192.0.2.1:443-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "192.0.2.1",
"port": "443",
"0": "192.0.2.1:443"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "33552.00",
"flow-id": "0",
"flow-type": 0,
"user": "root",
"command": "lighttpd",
"pid": "46564",
"fd": "11",
"proto": "tcp4"
},
"tcp4/[198.51.100.1:443-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "198.51.100.1",
"port": "443",
"0": "198.51.100.1:443"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "33552.00",
"flow-id": "0",
"flow-type": 0,
"user": "root",
"command": "lighttpd",
"pid": "46564",
"fd": "10",
"proto": "tcp4"
},
"tcp4/[127.0.0.1:443-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "127.0.0.1",
"port": "443",
"0": "127.0.0.1:443"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "33552.00",
"flow-id": "0",
"flow-type": 0,
"user": "root",
"command": "lighttpd",
"pid": "46564",
"fd": "7",
"proto": "tcp4"
},
"tcp4/[192.0.2.1:80-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "192.0.2.1",
"port": "80",
"0": "192.0.2.1:80"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "33552.00",
"flow-id": "0",
"flow-type": 0,
"user": "root",
"command": "lighttpd",
"pid": "46564",
"fd": "16",
"proto": "tcp4"
},
"tcp4/[*:8000-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "*",
"port": "8000",
"0": "*:8000"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "41043.28",
"flow-id": "0",
"flow-type": 0,
"user": "www",
"command": "lighttpd",
"pid": "7612",
"fd": "7",
"proto": "tcp4",
"listen-queue-sizes": {
"qlen": "0",
"incqlen": "0",
"maxqlen": "128"
}
},
"tcp6/[*:8000-*:*]": {
"protocol": "tcp6",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "*",
"port": "8000",
"0": "*:8000"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "41043.28",
"flow-id": "0",
"flow-type": 0,
"user": "www",
"command": "lighttpd",
"pid": "7612",
"fd": "4",
"proto": "tcp6",
"listen-queue-sizes": {
"qlen": "0",
"incqlen": "0",
"maxqlen": "128"
}
},
"tcp4/[192.0.2.1:8080-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "192.0.2.1",
"port": "8080",
"0": "192.0.2.1:8080"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "21096.55",
"flow-id": "0",
"flow-type": 0,
"user": "root",
"command": "crowdsec",
"pid": "90423",
"fd": "26",
"proto": "tcp4"
},
"tcp4/[127.0.0.1:6060-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "127.0.0.1",
"port": "6060",
"0": "127.0.0.1:6060"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "41044.32",
"flow-id": "0",
"flow-type": 0,
"user": "root",
"command": "crowdsec",
"pid": "90423",
"fd": "20",
"proto": "tcp4"
},
"tcp4/[192.0.2.1:10050-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "192.0.2.1",
"port": "10050",
"0": "192.0.2.1:10050"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "41044.31",
"flow-id": "0",
"flow-type": 0,
"user": "zabbix",
"command": "zabbix_age",
"pid": "70051",
"fd": "5",
"proto": "tcp4"
},
"tcp4/[192.0.2.1:22-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "192.0.2.1",
"port": "22",
"0": "192.0.2.1:22"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "41054.14",
"flow-id": "0",
"flow-type": 0,
"user": "root",
"command": "sshd",
"pid": "12554",
"fd": "6",
"proto": "tcp4"
},
"tcp4/[127.0.0.1:22-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "127.0.0.1",
"port": "22",
"0": "127.0.0.1:22"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "41054.14",
"flow-id": "0",
"flow-type": 0,
"user": "root",
"command": "sshd",
"pid": "12554",
"fd": "11",
"proto": "tcp4"
},
"tcp4/[203.0.113.1:53-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "203.0.113.1",
"port": "53",
"0": "203.0.113.1:53"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "9830.17",
"flow-id": "0",
"flow-type": 0,
"user": "unbound",
"command": "unbound",
"pid": "10992",
"fd": "116",
"proto": "tcp4"
},
"tcp4/[127.0.0.1:953-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "127.0.0.1",
"port": "953",
"0": "127.0.0.1:953"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "9830.17",
"flow-id": "0",
"flow-type": 0,
"user": "unbound",
"command": "unbound",
"pid": "10992",
"fd": "117",
"proto": "tcp4"
},
"tcp4/[127.0.0.1:8000-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "127.0.0.1",
"port": "8000",
"0": "127.0.0.1:8000"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "10006.15",
"flow-id": "0",
"flow-type": 0,
"user": "root",
"command": "kea-ctrl-a",
"pid": "14979",
"fd": "7",
"proto": "tcp4"
},
"tcp4/[127.0.0.1:2604-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "127.0.0.1",
"port": "2604",
"0": "127.0.0.1:2604"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "41047.92",
"flow-id": "0",
"flow-type": 0,
"user": "frr",
"command": "ospfd",
"pid": "89416",
"fd": "11",
"proto": "tcp4"
},
"udp4/[203.0.113.1:53-*:*]": {
"protocol": "udp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "203.0.113.1",
"port": "53",
"0": "203.0.113.1:53"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "b8a211",
"flow-type": 63,
"user": "unbound",
"command": "unbound",
"pid": "10992",
"fd": "115",
"proto": "udp4"
},
"udp4/[192.0.2.1:67-*:*]": {
"protocol": "udp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "192.0.2.1",
"port": "67",
"0": "192.0.2.1:67"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "b899f5",
"flow-type": 63,
"user": "root",
"command": "kea-dhcp4",
"pid": "12917",
"fd": "19",
"proto": "udp4"
},
"udp4/[*:123-*:*]": {
"protocol": "udp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "*",
"port": "123",
"0": "*:123"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "c23b49",
"flow-type": 63,
"user": "root",
"command": "ntpd",
"pid": "69031",
"fd": "21",
"proto": "udp4"
},
"udp6/[*:123-*:*]": {
"protocol": "udp6",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "*",
"port": "123",
"0": "*:123"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "0",
"flow-type": 0,
"user": "root",
"command": "ntpd",
"pid": "69031",
"fd": "20",
"proto": "udp6"
},
"udp4/[198.51.100.1:5353-*:*]": {
"protocol": "udp4",
"receive-bytes-waiting": 42052,
"send-bytes-waiting": 0,
"local": {
"address": "198.51.100.1",
"port": "5353",
"0": "198.51.100.1:5353"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 321536,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "13f3",
"flow-type": 63,
"user": "root",
"command": "mdns-repea",
"pid": "12222",
"fd": "10",
"proto": "udp4"
},
"udp4/[*:1900-*:*]": {
"protocol": "udp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "*",
"port": "1900",
"0": "*:1900"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "c27",
"flow-type": 63,
"user": "root",
"command": "udpbroadca",
"pid": "78873",
"fd": "3",
"proto": "udp4"
},
"udp4/[192.0.2.1:161-*:*]": {
"protocol": "udp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "192.0.2.1",
"port": "161",
"0": "192.0.2.1:161"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "130d",
"flow-type": 63,
"user": "root",
"command": "snmpd",
"pid": "53743",
"fd": "8",
"proto": "udp4"
},
"udp4/[*:51821-*:*]": {
"protocol": "udp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "*",
"port": "51821",
"0": "*:51821"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "945",
"flow-type": 63,
"user": "??",
"command": "??",
"pid": "??",
"fd": "??",
"proto": "udp4"
},
"udp6/[*:51821-*:*]": {
"protocol": "udp6",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "*",
"port": "51821",
"0": "*:51821"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "0",
"flow-type": 0,
"user": "??",
"command": "??",
"pid": "??",
"fd": "??",
"proto": "udp6"
},
"udp4/[*:29281-*:*]": {
"protocol": "udp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "*",
"port": "29281",
"0": "*:29281"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "c23c01",
"flow-type": 63,
"user": "??",
"command": "??",
"pid": "??",
"fd": "??",
"proto": "udp4"
},
"udp4/[*:*-*:*]": {
"protocol": "udp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "*",
"port": "*",
"0": "*:*"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "1441",
"flow-type": 63,
"user": "_lldpd",
"command": "lldpd",
"pid": "72768",
"fd": "6",
"proto": "udp4"
}
},
"Active UNIX domain sockets": {
"fffff80016c50c80 - /var/run/configd.socket": {
"address": "fffff80016c50c80",
"type": "stream",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"vnode": "0",
"connection": "fffff8006e7b8640",
"first-reference": "0",
"next-reference": "0",
"path": "/var/run/configd.socket"
}
}
}
}
+111
View File
@@ -0,0 +1,111 @@
"""carp_virtual_ip: one CARP address as a virtual IP, with its holder in the reading.
CARP differs from vip-manager and keepalived in what the caller can see. The
address is configured on every member, and the interface list of a firewall need
not show it at all -- so the interfaces cannot say who holds it. CARP's own state
can: the member in MASTER holds it, a BACKUP stands by. That is CARP protocol
knowledge, so it lives with the driver that speaks CARP (netOrk #1121), not in
napalm-device-types.
"""
from __future__ import annotations
import pytest
from napalm_opnsense.virtual_ips import carp_virtual_ip
def _entry(**overrides):
fields = {
"address": "172.22.8.1",
"prefix_length": 24,
"vhid": "108",
"state": "MASTER",
"instance": "MGMT",
"interface": "vtnet0",
}
fields.update(overrides)
return carp_virtual_ip(**fields)
def test_a_master_holds_the_address():
assert _entry() == {
"mechanism": "carp",
"instance": "MGMT",
"address": "172.22.8.1",
"prefix_length": 24,
"interface": "vtnet0",
"group_key": "vhid:108",
"running": True,
"holds": True,
}
def test_a_backup_stands_by():
entry = _entry(state="BACKUP")
assert entry is not None
assert (entry["running"], entry["holds"]) == (True, False)
@pytest.mark.parametrize("state", ["INIT", "DISABLED"])
def test_a_member_whose_carp_is_not_up_neither_runs_nor_holds(state):
entry = _entry(state=state)
assert entry is not None
assert (entry["running"], entry["holds"]) == (False, False)
@pytest.mark.parametrize("state", [None, "", "SOMETHING-NEW"])
def test_an_unknown_state_says_nothing_about_the_holder(state):
"""None leaves the holder to the interfaces, as for the other mechanisms."""
entry = _entry(state=state)
assert entry is not None
assert (entry["running"], entry["holds"]) == (None, None)
def test_the_state_is_read_case_blind():
entry = _entry(state=" master ")
assert entry is not None
assert entry["holds"] is True
def test_the_address_is_normalised():
entry = _entry(address="FD00::0001", prefix_length=64)
assert entry is not None
assert entry["address"] == "fd00::1"
def test_an_address_written_with_its_prefix_takes_that_prefix():
entry = _entry(address="10.42.100.1/24", prefix_length=None)
assert entry is not None
assert (entry["address"], entry["prefix_length"]) == ("10.42.100.1", 24)
@pytest.mark.parametrize("address", ["", "not-an-address", "10.0.0.300"])
def test_what_is_no_address_is_no_virtual_ip(address):
assert _entry(address=address) is None
@pytest.mark.parametrize("prefix", [33, -1])
def test_an_impossible_prefix_is_dropped(prefix):
entry = _entry(prefix_length=prefix)
assert entry is not None
assert entry["prefix_length"] is None
@pytest.mark.parametrize("vhid", [None, "", "abc"])
def test_without_a_numeric_vhid_there_is_no_group(vhid):
entry = _entry(vhid=vhid)
assert entry is not None
assert entry["group_key"] is None
def test_a_vhid_given_as_a_number_groups_the_same_way():
entry = _entry(vhid=108)
assert entry is not None
assert entry["group_key"] == "vhid:108"
def test_an_empty_instance_falls_back_to_the_vhid():
entry = _entry(instance="")
assert entry is not None
assert entry["instance"] == "vhid 108"
+109
View File
@@ -0,0 +1,109 @@
"""``get_dhcp_leases()``: an empty answer is only ever a real one (#27).
The getter tries Kea, then ISC DHCP, then falls back to the ARP table (rows
with ``state: "arp"``). A caller stores the answer as the server's lease set,
so ``[]`` must mean "a lease source answered and has no leases". When no lease
source answered and the ARP fallback gives nothing either, the getter raises:
an empty list there would wipe the leases the caller knows.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from napalm_opnsense.opnsense import OPNsenseDriver
KEA = "/api/kea/leases4/search"
ISC = "/api/dhcpv4/leases/searchlease"
ARP = "/api/diagnostics/interface/get_arp"
KEA_ROW = {"hwaddr": "AA:BB:CC:DD:EE:01", "address": "10.0.0.11", "hostname": "nas", "state": "0"}
ISC_ROW = {"mac": "aa:bb:cc:dd:ee:02", "address": "10.0.0.12", "ends": "1700000000"}
ARP_ROW = {"intf": "igb0", "mac": "AA:BB:CC:DD:EE:03", "ip": "10.0.0.13", "expires": "1200"}
def _driver(kea=None, isc=None, arp=None) -> OPNsenseDriver:
"""Each source answers its value, or raises it when it is an exception."""
def answer(value):
if isinstance(value, Exception):
raise value
return value
driver = OPNsenseDriver("gw-01", "user", "secret")
driver._get = MagicMock(side_effect=lambda path: answer({KEA: kea, ARP: arp}[path]))
driver._post = MagicMock(side_effect=lambda path, data=None: answer({ISC: isc}[path]))
return driver
def _macs(leases: list[dict]) -> list[str]:
return [lease["mac"] for lease in leases]
class TestALeaseSourceAnswers:
def test_kea_leases_come_first(self):
driver = _driver(kea={"rows": [KEA_ROW]}, isc=RuntimeError("404"), arp=RuntimeError("x"))
assert _macs(driver.get_dhcp_leases()) == ["aa:bb:cc:dd:ee:01"]
def test_isc_leases_when_kea_has_none(self):
driver = _driver(kea={"rows": []}, isc={"rows": [ISC_ROW]}, arp=RuntimeError("x"))
assert _macs(driver.get_dhcp_leases()) == ["aa:bb:cc:dd:ee:02"]
def test_isc_leases_when_kea_fails(self):
driver = _driver(kea=RuntimeError("404"), isc={"rows": [ISC_ROW]}, arp=RuntimeError("x"))
assert _macs(driver.get_dhcp_leases()) == ["aa:bb:cc:dd:ee:02"]
class TestTheArpFallback:
"""No source has leases: the ARP table stands in, marked ``state: "arp"`` (#1168)."""
def test_when_both_lease_sources_fail(self):
driver = _driver(kea=RuntimeError("404"), isc=RuntimeError("404"), arp=[ARP_ROW])
leases = driver.get_dhcp_leases()
assert leases == [
{
"mac": "aa:bb:cc:dd:ee:03",
"ip": "10.0.0.13",
"hostname": "",
"lease_end": 0,
"state": "arp",
}
]
def test_when_a_lease_source_answers_empty(self):
driver = _driver(kea={"rows": []}, isc=RuntimeError("404"), arp=[ARP_ROW])
assert [lease["state"] for lease in driver.get_dhcp_leases()] == ["arp"]
class TestAnEmptyAnswer:
"""[] only when Kea or ISC answered and has no leases."""
def test_kea_empty_and_no_arp_fallback(self):
driver = _driver(kea={"rows": []}, isc=RuntimeError("404"), arp=RuntimeError("timeout"))
assert driver.get_dhcp_leases() == []
def test_isc_empty_and_an_empty_arp_table(self):
driver = _driver(kea=RuntimeError("404"), isc={"rows": []}, arp=[])
assert driver.get_dhcp_leases() == []
class TestNothingAnswered:
"""No lease source answered and the fallback gave no rows: that is no answer."""
def test_all_three_fail(self):
driver = _driver(
kea=RuntimeError("kea down"), isc=RuntimeError("isc down"), arp=RuntimeError("arp down")
)
with pytest.raises(RuntimeError) as excinfo:
driver.get_dhcp_leases()
message = str(excinfo.value)
assert "kea down" in message
assert "isc down" in message
assert "arp down" in message
def test_lease_sources_fail_and_the_arp_table_is_empty(self):
driver = _driver(kea=RuntimeError("kea down"), isc=RuntimeError("isc down"), arp=[])
with pytest.raises(RuntimeError, match="kea down"):
driver.get_dhcp_leases()
+396 -88
View File
@@ -1,17 +1,19 @@
"""Unit tests for OPNsenseDriver — no real device required.""" """Unit tests for OPNsenseDriver — no real device required."""
import json import json
import pytest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from napalm_opnsense.opnsense import OPNsenseDriver import pytest
from napalm.base.exceptions import ConnectionException, ConnectionClosedException import requests
from napalm.base.exceptions import ConnectionClosedException, ConnectionException
from napalm_opnsense.opnsense import OPNsenseDriver
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Fixtures # Fixtures
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.fixture @pytest.fixture
def driver(): def driver():
"""Return a driver instance with a mocked requests.Session.""" """Return a driver instance with a mocked requests.Session."""
@@ -109,6 +111,7 @@ ARP_RESPONSE = {
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _make_json_response(data): def _make_json_response(data):
mock_resp = MagicMock() mock_resp = MagicMock()
mock_resp.json.return_value = data mock_resp.json.return_value = data
@@ -120,6 +123,7 @@ def _make_json_response(data):
# open() / close() / is_alive() # open() / close() / is_alive()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestOpenClose: class TestOpenClose:
def test_open_raises_connection_exception_on_error(self): def test_open_raises_connection_exception_on_error(self):
drv = OPNsenseDriver( drv = OPNsenseDriver(
@@ -165,6 +169,7 @@ class TestIsAlive:
# _get() # _get()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestInternalGet: class TestInternalGet:
def test_raises_when_no_session(self): def test_raises_when_no_session(self):
drv = OPNsenseDriver("host", "u", "p") drv = OPNsenseDriver("host", "u", "p")
@@ -184,41 +189,40 @@ class TestInternalGet:
# get_facts() # get_facts()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestGetFacts: class TestGetFacts:
def test_returns_required_keys(self, driver): def test_returns_required_keys(self, driver):
driver._get = lambda path: ( driver._get = lambda path: STATUS_RESPONSE if "status" in path else INTERFACES_RESPONSE
STATUS_RESPONSE if "status" in path else INTERFACES_RESPONSE
)
facts = driver.get_facts() facts = driver.get_facts()
for key in ("vendor", "model", "hostname", "fqdn", "os_version", for key in (
"serial_number", "uptime", "interface_list"): "vendor",
"model",
"hostname",
"fqdn",
"os_version",
"serial_number",
"uptime",
"interface_list",
):
assert key in facts assert key in facts
def test_vendor_constant(self, driver): def test_vendor_constant(self, driver):
driver._get = lambda path: ( driver._get = lambda path: STATUS_RESPONSE if "status" in path else INTERFACES_RESPONSE
STATUS_RESPONSE if "status" in path else INTERFACES_RESPONSE
)
facts = driver.get_facts() facts = driver.get_facts()
assert facts["vendor"] == "OPNsense" assert facts["vendor"] == "OPNsense"
def test_hostname_parsed(self, driver): def test_hostname_parsed(self, driver):
driver._get = lambda path: ( driver._get = lambda path: STATUS_RESPONSE if "status" in path else INTERFACES_RESPONSE
STATUS_RESPONSE if "status" in path else INTERFACES_RESPONSE
)
facts = driver.get_facts() facts = driver.get_facts()
assert facts["hostname"] == "opnsense01" assert facts["hostname"] == "opnsense01"
def test_os_version_parsed(self, driver): def test_os_version_parsed(self, driver):
driver._get = lambda path: ( driver._get = lambda path: STATUS_RESPONSE if "status" in path else INTERFACES_RESPONSE
STATUS_RESPONSE if "status" in path else INTERFACES_RESPONSE
)
facts = driver.get_facts() facts = driver.get_facts()
assert facts["os_version"] == "24.7" assert facts["os_version"] == "24.7"
def test_interface_list_populated(self, driver): def test_interface_list_populated(self, driver):
driver._get = lambda path: ( driver._get = lambda path: STATUS_RESPONSE if "status" in path else INTERFACES_RESPONSE
STATUS_RESPONSE if "status" in path else INTERFACES_RESPONSE
)
facts = driver.get_facts() facts = driver.get_facts()
assert "em0" in facts["interface_list"] assert "em0" in facts["interface_list"]
assert "em1" in facts["interface_list"] assert "em1" in facts["interface_list"]
@@ -234,16 +238,12 @@ class TestGetFacts:
assert facts["interface_list"] == [] assert facts["interface_list"] == []
def test_serial_number(self, driver): def test_serial_number(self, driver):
driver._get = lambda path: ( driver._get = lambda path: STATUS_RESPONSE if "status" in path else INTERFACES_RESPONSE
STATUS_RESPONSE if "status" in path else INTERFACES_RESPONSE
)
facts = driver.get_facts() facts = driver.get_facts()
assert facts["serial_number"] == "ABC123" assert facts["serial_number"] == "ABC123"
def test_uptime(self, driver): def test_uptime(self, driver):
driver._get = lambda path: ( driver._get = lambda path: STATUS_RESPONSE if "status" in path else INTERFACES_RESPONSE
STATUS_RESPONSE if "status" in path else INTERFACES_RESPONSE
)
facts = driver.get_facts() facts = driver.get_facts()
assert facts["uptime"] == 12345 assert facts["uptime"] == 12345
@@ -252,6 +252,7 @@ class TestGetFacts:
# get_interfaces() # get_interfaces()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestGetInterfaces: class TestGetInterfaces:
def test_interface_count(self, driver): def test_interface_count(self, driver):
driver._get = lambda path: INTERFACES_RESPONSE driver._get = lambda path: INTERFACES_RESPONSE
@@ -310,6 +311,7 @@ class TestGetInterfaces:
# get_interfaces_ip() # get_interfaces_ip()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestGetInterfacesIp: class TestGetInterfacesIp:
def test_entry_count(self, driver): def test_entry_count(self, driver):
driver._get = lambda path: ADDRESSES_RESPONSE driver._get = lambda path: ADDRESSES_RESPONSE
@@ -339,6 +341,7 @@ class TestGetInterfacesIp:
# get_arp_table() # get_arp_table()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestGetArpTable: class TestGetArpTable:
def test_entry_count(self, driver): def test_entry_count(self, driver):
driver._get = lambda path: ARP_RESPONSE driver._get = lambda path: ARP_RESPONSE
@@ -418,11 +421,18 @@ class TestGetInterfacesCounters:
driver._get = lambda path: INTERFACE_STATISTICS_RESPONSE driver._get = lambda path: INTERFACE_STATISTICS_RESPONSE
entry = driver.get_interfaces_counters()["em0"] entry = driver.get_interfaces_counters()["em0"]
for key in ( for key in (
"tx_errors", "rx_errors", "tx_discards", "rx_discards", "tx_errors",
"tx_octets", "rx_octets", "rx_errors",
"tx_unicast_packets", "rx_unicast_packets", "tx_discards",
"tx_multicast_packets", "rx_multicast_packets", "rx_discards",
"tx_broadcast_packets", "rx_broadcast_packets", "tx_octets",
"rx_octets",
"tx_unicast_packets",
"rx_unicast_packets",
"tx_multicast_packets",
"rx_multicast_packets",
"tx_broadcast_packets",
"rx_broadcast_packets",
): ):
assert key in entry assert key in entry
@@ -504,7 +514,8 @@ class TestGetEnvironment:
def test_temperature_alert_thresholds(self, driver): def test_temperature_alert_thresholds(self, driver):
driver._get = lambda path: ( driver._get = lambda path: (
{"data": [{"device": "cpu0", "temperature": "85.0"}]} {"data": [{"device": "cpu0", "temperature": "85.0"}]}
if "temperature" in path else SYSTEM_RESOURCES_RESPONSE if "temperature" in path
else SYSTEM_RESOURCES_RESPONSE
) )
env = driver.get_environment() env = driver.get_environment()
assert env["temperature"]["cpu0"]["is_alert"] is True assert env["temperature"]["cpu0"]["is_alert"] is True
@@ -512,6 +523,7 @@ class TestGetEnvironment:
def test_temperature_endpoint_failing_gracefully(self, driver): def test_temperature_endpoint_failing_gracefully(self, driver):
"""Driver must not raise if temperature endpoint is unavailable.""" """Driver must not raise if temperature endpoint is unavailable."""
def fake_get(path): def fake_get(path):
if "temperature" in path: if "temperature" in path:
raise Exception("no sensor data") raise Exception("no sensor data")
@@ -593,19 +605,22 @@ class TestGetRouteTo:
def test_filter_by_protocol(self, driver): def test_filter_by_protocol(self, driver):
driver._get = lambda path: ROUTES_RESPONSE driver._get = lambda path: ROUTES_RESPONSE
routes = driver.get_route_to(protocol="static") routes = driver.get_route_to(protocol="static")
assert all( assert all(e["protocol"] == "static" for entries in routes.values() for e in entries)
e["protocol"] == "static"
for entries in routes.values()
for e in entries
)
def test_required_keys_in_entry(self, driver): def test_required_keys_in_entry(self, driver):
driver._get = lambda path: ROUTES_RESPONSE driver._get = lambda path: ROUTES_RESPONSE
entry = driver.get_route_to()["0.0.0.0/0"][0] entry = driver.get_route_to()["0.0.0.0/0"][0]
for key in ( for key in (
"protocol", "current_active", "last_active", "age", "protocol",
"next_hop", "outgoing_interface", "selected_next_hop", "current_active",
"preference", "inactive_reason", "routing_table", "last_active",
"age",
"next_hop",
"outgoing_interface",
"selected_next_hop",
"preference",
"inactive_reason",
"routing_table",
"protocol_attributes", "protocol_attributes",
): ):
assert key in entry assert key in entry
@@ -706,15 +721,21 @@ class TestGetLldpNeighborsDetail:
detail = driver.get_lldp_neighbors_detail() detail = driver.get_lldp_neighbors_detail()
entry = detail["em0"][0] entry = detail["em0"][0]
for key in ( for key in (
"remote_chassis_id", "remote_system_name", "remote_port", "remote_chassis_id",
"remote_port_description", "remote_system_description", "remote_system_name",
"remote_system_capab", "remote_system_enable_capab", "remote_port",
"remote_port_description",
"remote_system_description",
"remote_system_capab",
"remote_system_enable_capab",
): ):
assert key in entry assert key in entry
def test_chassis_id(self, driver): def test_chassis_id(self, driver):
driver._get = lambda path: LLDP_RESPONSE driver._get = lambda path: LLDP_RESPONSE
assert driver.get_lldp_neighbors_detail()["em0"][0]["remote_chassis_id"] == "aa:bb:cc:dd:ee:ff" assert (
driver.get_lldp_neighbors_detail()["em0"][0]["remote_chassis_id"] == "aa:bb:cc:dd:ee:ff"
)
def test_system_name(self, driver): def test_system_name(self, driver):
driver._get = lambda path: LLDP_RESPONSE driver._get = lambda path: LLDP_RESPONSE
@@ -1009,12 +1030,19 @@ class TestGetBgpNeighbors:
def test_required_peer_keys(self, driver): def test_required_peer_keys(self, driver):
driver._get = self._fake_get driver._get = self._fake_get
peer = driver.get_bgp_neighbors()["global"]["peers"]["10.0.0.1"] peer = driver.get_bgp_neighbors()["global"]["peers"]["10.0.0.1"]
for key in ("local_as", "remote_as", "remote_id", "is_up", "is_enabled", for key in (
"description", "uptime", "address_family"): "local_as",
"remote_as",
"remote_id",
"is_up",
"is_enabled",
"description",
"uptime",
"address_family",
):
assert key in peer assert key in peer
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# get_certificates() # get_certificates()
# #
@@ -1179,15 +1207,24 @@ class TestGetDdnsStatus:
RADIUS_CLIENTS_RESPONSE = { RADIUS_CLIENTS_RESPONSE = {
"rows": [ "rows": [
{"uuid": "e5483f1b-936b-47ba-8cca-24d56ad643c6", "enabled": "1", {
"name": "ap-lobby", "secret": "s3cr3t", "ip": "10.0.0.5/32"}, "uuid": "e5483f1b-936b-47ba-8cca-24d56ad643c6",
"enabled": "1",
"name": "ap-lobby",
"secret": "s3cr3t",
"ip": "10.0.0.5/32",
},
] ]
} }
RADIUS_USERS_RESPONSE = { RADIUS_USERS_RESPONSE = {
"rows": [ "rows": [
{"uuid": "71da23fd-03f3-4e1f-a7e1-bb25645b981e", "enabled": "1", {
"username": "jdoe", "password": "hunter2"}, "uuid": "71da23fd-03f3-4e1f-a7e1-bb25645b981e",
"enabled": "1",
"username": "jdoe",
"password": "hunter2",
},
] ]
} }
@@ -1236,10 +1273,13 @@ class TestCreateRadiusClient:
def test_validation_failure_does_not_reconfigure(self, driver): def test_validation_failure_does_not_reconfigure(self, driver):
calls = [] calls = []
driver._post = lambda path, data=None: calls.append(path) or { driver._post = lambda path, data=None: (
"result": "failed", calls.append(path)
"validations": {"client.name": "A value is required."}, or {
} "result": "failed",
"validations": {"client.name": "A value is required."},
}
)
result = driver.create_radius_client("", "10.0.0.5/32", "s3cr3t") result = driver.create_radius_client("", "10.0.0.5/32", "s3cr3t")
assert result["success"] is False assert result["success"] is False
assert result["validations"] == {"client.name": "A value is required."} assert result["validations"] == {"client.name": "A value is required."}
@@ -1319,10 +1359,13 @@ class TestCreateRadiusUser:
def test_validation_failure_does_not_reconfigure(self, driver): def test_validation_failure_does_not_reconfigure(self, driver):
calls = [] calls = []
driver._post = lambda path, data=None: calls.append(path) or { driver._post = lambda path, data=None: (
"result": "failed", calls.append(path)
"validations": {"user.username": "A value is required."}, or {
} "result": "failed",
"validations": {"user.username": "A value is required."},
}
)
result = driver.create_radius_user("", "hunter2") result = driver.create_radius_user("", "hunter2")
assert result["success"] is False assert result["success"] is False
assert calls == ["/api/freeradius/user/add_user"] assert calls == ["/api/freeradius/user/add_user"]
@@ -1357,6 +1400,7 @@ class TestDeleteRadiusUser:
# _post() # _post()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestInternalPost: class TestInternalPost:
def test_raises_when_no_session(self): def test_raises_when_no_session(self):
drv = OPNsenseDriver("host", "u", "p") drv = OPNsenseDriver("host", "u", "p")
@@ -1392,6 +1436,7 @@ class TestInternalPost:
# send_wake_on_lan() # send_wake_on_lan()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSendWakeOnLan: class TestSendWakeOnLan:
def test_raises_value_error_without_interface(self, driver): def test_raises_value_error_without_interface(self, driver):
with pytest.raises(ValueError): with pytest.raises(ValueError):
@@ -1412,7 +1457,10 @@ class TestSendWakeOnLan:
# success — the underlying `wol` CLI tool typically prints nothing. # success — the underlying `wol` CLI tool typically prints nothing.
driver.session.post.return_value = _make_json_response({"status": ""}) driver.session.post.return_value = _make_json_response({"status": ""})
result = driver.send_wake_on_lan("AA:BB:CC:DD:EE:FF", interface="lan") result = driver.send_wake_on_lan("AA:BB:CC:DD:EE:FF", interface="lan")
assert result == {"success": True, "output": "Magic packet sent to AA:BB:CC:DD:EE:FF via lan"} assert result == {
"success": True,
"output": "Magic packet sent to AA:BB:CC:DD:EE:FF via lan",
}
def test_returns_failure_on_error_status(self, driver): def test_returns_failure_on_error_status(self, driver):
driver.session.post.return_value = _make_json_response( driver.session.post.return_value = _make_json_response(
@@ -1440,10 +1488,12 @@ class TestSendWakeOnLan:
# load_merge_candidate() # load_merge_candidate()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
VALID_ROUTES_CONFIG = json.dumps([ VALID_ROUTES_CONFIG = json.dumps(
{"network": "10.0.0.0/8", "gateway": "WAN_GW", "descr": "internal"}, [
{"network": "0.0.0.0/0", "gateway": "WAN_GW", "descr": "default"}, {"network": "10.0.0.0/8", "gateway": "WAN_GW", "descr": "internal"},
]) {"network": "0.0.0.0/0", "gateway": "WAN_GW", "descr": "default"},
]
)
class TestLoadMergeCandidate: class TestLoadMergeCandidate:
@@ -1466,38 +1516,45 @@ class TestLoadMergeCandidate:
def test_raises_if_both_args_given(self, driver): def test_raises_if_both_args_given(self, driver):
from napalm.base.exceptions import MergeConfigException from napalm.base.exceptions import MergeConfigException
with pytest.raises(MergeConfigException): with pytest.raises(MergeConfigException):
driver.load_merge_candidate(filename="f.json", config="{}") driver.load_merge_candidate(filename="f.json", config="{}")
def test_raises_if_no_args_given(self, driver): def test_raises_if_no_args_given(self, driver):
from napalm.base.exceptions import MergeConfigException from napalm.base.exceptions import MergeConfigException
with pytest.raises(MergeConfigException): with pytest.raises(MergeConfigException):
driver.load_merge_candidate() driver.load_merge_candidate()
def test_raises_on_invalid_json(self, driver): def test_raises_on_invalid_json(self, driver):
from napalm.base.exceptions import MergeConfigException from napalm.base.exceptions import MergeConfigException
with pytest.raises(MergeConfigException, match="Invalid JSON"): with pytest.raises(MergeConfigException, match="Invalid JSON"):
driver.load_merge_candidate(config="not json {{{") driver.load_merge_candidate(config="not json {{{")
def test_raises_if_not_a_list(self, driver): def test_raises_if_not_a_list(self, driver):
from napalm.base.exceptions import MergeConfigException from napalm.base.exceptions import MergeConfigException
with pytest.raises(MergeConfigException, match="array"): with pytest.raises(MergeConfigException, match="array"):
driver.load_merge_candidate(config='{"network": "1.0.0.0/8"}') driver.load_merge_candidate(config='{"network": "1.0.0.0/8"}')
def test_raises_if_route_missing_network(self, driver): def test_raises_if_route_missing_network(self, driver):
from napalm.base.exceptions import MergeConfigException from napalm.base.exceptions import MergeConfigException
bad = json.dumps([{"gateway": "GW1"}]) bad = json.dumps([{"gateway": "GW1"}])
with pytest.raises(MergeConfigException, match="'network'"): with pytest.raises(MergeConfigException, match="'network'"):
driver.load_merge_candidate(config=bad) driver.load_merge_candidate(config=bad)
def test_raises_if_route_missing_gateway(self, driver): def test_raises_if_route_missing_gateway(self, driver):
from napalm.base.exceptions import MergeConfigException from napalm.base.exceptions import MergeConfigException
bad = json.dumps([{"network": "10.0.0.0/8"}]) bad = json.dumps([{"network": "10.0.0.0/8"}])
with pytest.raises(MergeConfigException, match="'gateway'"): with pytest.raises(MergeConfigException, match="'gateway'"):
driver.load_merge_candidate(config=bad) driver.load_merge_candidate(config=bad)
def test_raises_on_missing_file(self, driver): def test_raises_on_missing_file(self, driver):
from napalm.base.exceptions import MergeConfigException from napalm.base.exceptions import MergeConfigException
with pytest.raises(MergeConfigException, match="Cannot read"): with pytest.raises(MergeConfigException, match="Cannot read"):
driver.load_merge_candidate(filename="/nonexistent/path/routes.json") driver.load_merge_candidate(filename="/nonexistent/path/routes.json")
@@ -1531,9 +1588,9 @@ class TestCompareConfig:
assert "WAN_GW" in diff assert "WAN_GW" in diff
def test_empty_diff_when_config_matches(self, driver): def test_empty_diff_when_config_matches(self, driver):
same_config = json.dumps([ same_config = json.dumps(
{"network": "192.168.1.0/24", "gateway": "LAN_GW", "descr": "lan", "disabled": "0"} [{"network": "192.168.1.0/24", "gateway": "LAN_GW", "descr": "lan", "disabled": "0"}]
]) )
driver._get = lambda path: SEARCH_ROUTE_RESPONSE driver._get = lambda path: SEARCH_ROUTE_RESPONSE
driver.load_merge_candidate(config=same_config) driver.load_merge_candidate(config=same_config)
diff = driver.compare_config() diff = driver.compare_config()
@@ -1546,7 +1603,11 @@ class TestCompareConfig:
BACKUPS_RESPONSE = { BACKUPS_RESPONSE = {
"items": [ "items": [
{"id": "config-opnsense01-1234567890.xml", "time": "1234567890", "description": "before change"}, {
"id": "config-opnsense01-1234567890.xml",
"time": "1234567890",
"description": "before change",
},
{"id": "config-opnsense01-1234567800.xml", "time": "1234567800", "description": "initial"}, {"id": "config-opnsense01-1234567800.xml", "time": "1234567800", "description": "initial"},
] ]
} }
@@ -1555,6 +1616,7 @@ BACKUPS_RESPONSE = {
class TestCommitConfig: class TestCommitConfig:
def test_raises_without_candidate(self, driver): def test_raises_without_candidate(self, driver):
from napalm.base.exceptions import MergeConfigException from napalm.base.exceptions import MergeConfigException
with pytest.raises(MergeConfigException, match="No candidate"): with pytest.raises(MergeConfigException, match="No candidate"):
driver.commit_config() driver.commit_config()
@@ -1588,8 +1650,9 @@ class TestCommitConfig:
assert driver._pre_commit_backup_id is None assert driver._pre_commit_backup_id is None
def test_raises_on_api_error(self, driver): def test_raises_on_api_error(self, driver):
from napalm.base.exceptions import MergeConfigException
import requests as _req import requests as _req
from napalm.base.exceptions import MergeConfigException
driver._get = lambda path: BACKUPS_RESPONSE driver._get = lambda path: BACKUPS_RESPONSE
driver.load_merge_candidate(config=VALID_ROUTES_CONFIG) driver.load_merge_candidate(config=VALID_ROUTES_CONFIG)
driver.session.post.side_effect = _req.exceptions.RequestException("timeout") driver.session.post.side_effect = _req.exceptions.RequestException("timeout")
@@ -1601,6 +1664,7 @@ class TestCommitConfig:
# discard_config() # discard_config()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestDiscardConfig: class TestDiscardConfig:
def test_clears_candidate(self, driver): def test_clears_candidate(self, driver):
driver.load_merge_candidate(config=VALID_ROUTES_CONFIG) driver.load_merge_candidate(config=VALID_ROUTES_CONFIG)
@@ -1616,6 +1680,7 @@ class TestDiscardConfig:
# rollback() # rollback()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestRollback: class TestRollback:
def test_noop_when_no_backups_and_no_commit(self, driver): def test_noop_when_no_backups_and_no_commit(self, driver):
driver._get = lambda path: {"items": []} driver._get = lambda path: {"items": []}
@@ -1654,26 +1719,79 @@ class TestRollback:
# get_config() — candidate slot # get_config() — candidate slot
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestGetConfigCandidate: class TestGetConfigCandidate:
def test_candidate_empty_without_staged_config(self, driver): def test_candidate_empty_without_staged_config(self, driver):
driver._get = lambda path: "<opnsense/>" driver._get_text = lambda path: "<opnsense/>"
result = driver.get_config() result = driver.get_config()
assert result["candidate"] == "" assert result["candidate"] == ""
def test_candidate_contains_staged_routes(self, driver): def test_candidate_contains_staged_routes(self, driver):
driver._get = lambda path: "<opnsense/>" driver._get_text = lambda path: "<opnsense/>"
driver.load_merge_candidate(config=VALID_ROUTES_CONFIG) driver.load_merge_candidate(config=VALID_ROUTES_CONFIG)
result = driver.get_config() result = driver.get_config()
assert "WAN_GW" in result["candidate"] assert "WAN_GW" in result["candidate"]
def test_candidate_is_valid_json(self, driver): def test_candidate_is_valid_json(self, driver):
driver._get = lambda path: "<opnsense/>" driver._get_text = lambda path: "<opnsense/>"
driver.load_merge_candidate(config=VALID_ROUTES_CONFIG) driver.load_merge_candidate(config=VALID_ROUTES_CONFIG)
result = driver.get_config() result = driver.get_config()
parsed = json.loads(result["candidate"]) parsed = json.loads(result["candidate"])
assert isinstance(parsed, list) assert isinstance(parsed, list)
# ---------------------------------------------------------------------------
# get_config() — running/startup
#
# /api/core/backup/download/this answers with config.xml itself, not JSON.
# These go through the real session path: a stubbed _get hid for months that
# the getter called .json() on XML and always returned an empty "running".
# ---------------------------------------------------------------------------
CONFIG_XML = (
'<?xml version="1.0"?>\n<opnsense><system><hostname>gw</hostname></system></opnsense>\n'
)
def _make_xml_response(text):
mock_resp = MagicMock()
mock_resp.text = text
mock_resp.json.side_effect = json.JSONDecodeError("Expecting value", text, 0)
mock_resp.raise_for_status.return_value = None
return mock_resp
class TestGetConfigRunning:
def test_running_is_the_xml_the_device_sent(self, driver):
driver.session.get.return_value = _make_xml_response(CONFIG_XML)
result = driver.get_config(retrieve="running")
assert result["running"] == CONFIG_XML
def test_startup_mirrors_running(self, driver):
driver.session.get.return_value = _make_xml_response(CONFIG_XML)
result = driver.get_config()
assert result["startup"] == result["running"] == CONFIG_XML
def test_reads_the_backup_download_endpoint(self, driver):
driver.session.get.return_value = _make_xml_response(CONFIG_XML)
driver.get_config(retrieve="running")
url = driver.session.get.call_args.args[0]
assert url.endswith("/api/core/backup/download/this")
def test_failed_read_raises_instead_of_returning_empty(self, driver):
# An empty "running" says "the device has no configuration"; the
# caller needs the reason, e.g. a key without backup privilege.
resp = MagicMock()
resp.raise_for_status.side_effect = requests.HTTPError("403 Forbidden")
driver.session.get.return_value = resp
with pytest.raises(requests.HTTPError, match="403"):
driver.get_config(retrieve="running")
def test_candidate_only_does_not_touch_the_device(self, driver):
driver.get_config(retrieve="candidate")
driver.session.get.assert_not_called()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# create_dhcp_reservation() # create_dhcp_reservation()
# #
@@ -1716,7 +1834,9 @@ class TestCreateDhcpReservation:
assert payload["description"] == "[netork]" assert payload["description"] == "[netork]"
reconfigure_call = driver.session.post.call_args_list[2] reconfigure_call = driver.session.post.call_args_list[2]
assert reconfigure_call.args[0] == "https://opnsense.example.com/api/kea/service/reconfigure" assert (
reconfigure_call.args[0] == "https://opnsense.example.com/api/kea/service/reconfigure"
)
def test_updates_existing_reservation_for_same_ip(self, driver): def test_updates_existing_reservation_for_same_ip(self, driver):
driver.session.get.return_value = _make_json_response(KEA_SUBNETS_RESPONSE) driver.session.get.return_value = _make_json_response(KEA_SUBNETS_RESPONSE)
@@ -1736,6 +1856,83 @@ class TestCreateDhcpReservation:
== "https://opnsense.example.com/api/kea/dhcpv4/setReservation/existing-uuid-456" == "https://opnsense.example.com/api/kea/dhcpv4/setReservation/existing-uuid-456"
) )
# A reconfigure stops and starts Kea (ApiMutableServiceControllerBase,
# reconfigureForceRestart() is true for Kea). Writing a reservation that
# is already there, and restarting the daemon for it, once left a
# firewall without DHCP (#1187) -- so an unchanged one is not touched.
@pytest.mark.parametrize(
"subnet",
["172.22.8.0/24", "6854cab3-ebb5-4031-b987-0edcc6723546"],
ids=["subnet-as-cidr", "subnet-as-uuid"],
)
def test_unchanged_reservation_is_neither_written_nor_reconfigured(self, driver, subnet):
driver.session.get.return_value = _make_json_response(KEA_SUBNETS_RESPONSE)
driver.session.post.side_effect = [
_make_json_response(
{
"rows": [
{
"uuid": "existing-uuid-456",
"subnet": subnet,
"ip_address": "172.22.8.253",
# Kea keeps the MAC as it was entered -- case and
# separator do not make it a different reservation.
"hw_address": "02-AA-BB-CC-DD-EE",
"hostname": "new-vm",
"description": "[netork]",
}
]
}
),
]
driver.create_dhcp_reservation(
mac="02:aa:bb:cc:dd:ee", ip="172.22.8.253", hostname="new-vm"
)
# searchReservation only -- no setReservation, no service/reconfigure.
assert driver.session.post.call_count == 1
@pytest.mark.parametrize(
"field, live_value",
[
("hw_address", "02:aa:bb:cc:dd:ff"),
("hostname", "old-vm"),
("description", "made by hand"),
("subnet", "172.22.0.0/24"),
],
)
def test_reservation_differing_in_one_field_is_written_and_reconfigured(
self, driver, field, live_value
):
row = {
"uuid": "existing-uuid-456",
"subnet": "172.22.8.0/24",
"ip_address": "172.22.8.253",
"hw_address": "02:aa:bb:cc:dd:ee",
"hostname": "new-vm",
"description": "[netork]",
}
row[field] = live_value
driver.session.get.return_value = _make_json_response(KEA_SUBNETS_RESPONSE)
driver.session.post.side_effect = [
_make_json_response({"rows": [row]}),
_make_json_response({"result": "saved", "uuid": "existing-uuid-456"}),
_make_json_response({"status": "ok"}),
]
driver.create_dhcp_reservation(
mac="02:aa:bb:cc:dd:ee", ip="172.22.8.253", hostname="new-vm"
)
paths = [c.args[0] for c in driver.session.post.call_args_list]
assert paths == [
"https://opnsense.example.com/api/kea/dhcpv4/searchReservation",
"https://opnsense.example.com/api/kea/dhcpv4/setReservation/existing-uuid-456",
"https://opnsense.example.com/api/kea/service/reconfigure",
]
def test_raises_when_ip_not_in_any_kea_subnet(self, driver): def test_raises_when_ip_not_in_any_kea_subnet(self, driver):
driver.session.get.return_value = _make_json_response(KEA_SUBNETS_RESPONSE) driver.session.get.return_value = _make_json_response(KEA_SUBNETS_RESPONSE)
@@ -1806,7 +2003,9 @@ class TestDeleteDhcpReservationAndLease:
== "https://opnsense.example.com/api/kea/dhcpv4/delReservation/existing-uuid-456" == "https://opnsense.example.com/api/kea/dhcpv4/delReservation/existing-uuid-456"
) )
reconfigure_call = driver.session.post.call_args_list[2] reconfigure_call = driver.session.post.call_args_list[2]
assert reconfigure_call.args[0] == "https://opnsense.example.com/api/kea/service/reconfigure" assert (
reconfigure_call.args[0] == "https://opnsense.example.com/api/kea/service/reconfigure"
)
del_lease_call = driver.session.post.call_args_list[3] del_lease_call = driver.session.post.call_args_list[3]
# IP is a URL path segment, not a JSON body field — verified live # IP is a URL path segment, not a JSON body field — verified live
# against a real OPNsense instance; a {"ips": [ip]} body (the # against a real OPNsense instance; a {"ips": [ip]} body (the
@@ -1878,6 +2077,7 @@ class TestDeleteDhcpReservationAndLease:
# apply_firewall_rule / commit_firewall_rules # apply_firewall_rule / commit_firewall_rules
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestApplyFirewallRule: class TestApplyFirewallRule:
def test_add_calls_addRule_with_translated_fields(self, driver): def test_add_calls_addRule_with_translated_fields(self, driver):
calls = [] calls = []
@@ -2228,7 +2428,9 @@ class TestGetDhcpSubnets:
paths = [] paths = []
driver._get = lambda path: ( driver._get = lambda path: (
paths.append(path) paths.append(path)
or (KEA_SUBNET_SEARCH_RESPONSE if "searchSubnet" in path else KEA_SUBNET_DETAIL_RESPONSE) or (
KEA_SUBNET_SEARCH_RESPONSE if "searchSubnet" in path else KEA_SUBNET_DETAIL_RESPONSE
)
) )
driver.get_dhcp_subnets() driver.get_dhcp_subnets()
@@ -2363,10 +2565,7 @@ class TestApplyDhcpSubnet:
} }
) )
assert ( assert calls[0][1]["subnet"]["pools"] == "10.10.99.10-10.10.99.20\n10.10.99.30-10.10.99.40"
calls[0][1]["subnet"]["pools"]
== "10.10.99.10-10.10.99.20\n10.10.99.30-10.10.99.40"
)
def test_explicit_option_data_disables_autocollect(self, driver): def test_explicit_option_data_disables_autocollect(self, driver):
"""Kea would otherwise re-fill routers/DNS/NTP and fight the desired state.""" """Kea would otherwise re-fill routers/DNS/NTP and fight the desired state."""
@@ -2425,8 +2624,15 @@ class TestKeaSubnetRecordKey:
# DNS host overrides # DNS host overrides
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _override_row(uuid, host="opnsense-office", domain="office.local",
ip="10.10.0.1", description="[netork]", **extra): def _override_row(
uuid,
host="opnsense-office",
domain="office.local",
ip="10.10.0.1",
description="[netork]",
**extra,
):
row = { row = {
"uuid": uuid, "uuid": uuid,
"hostname": host, "hostname": host,
@@ -2512,9 +2718,17 @@ class TestSyncDnsZone:
rows = [_override_row("uuid-1"), _override_row("uuid-2"), _override_row("uuid-3")] rows = [_override_row("uuid-1"), _override_row("uuid-2"), _override_row("uuid-3")]
calls = self._record_calls(driver, rows) calls = self._record_calls(driver, rows)
driver.sync_dns_zone("office.local", [ driver.sync_dns_zone(
{"hostname": "opnsense-office", "ip": "10.10.0.1", "record_type": "A", "enabled": True} "office.local",
]) [
{
"hostname": "opnsense-office",
"ip": "10.10.0.1",
"record_type": "A",
"enabled": True,
}
],
)
deleted = [p for p, _ in calls if "delhostoverride" in p] deleted = [p for p, _ in calls if "delhostoverride" in p]
assert sorted(p.rsplit("/", 1)[-1] for p in deleted) == ["uuid-1", "uuid-2", "uuid-3"] assert sorted(p.rsplit("/", 1)[-1] for p in deleted) == ["uuid-1", "uuid-2", "uuid-3"]
@@ -2537,14 +2751,108 @@ class TestSyncDnsZone:
in; the device still ends up with a single override.""" in; the device still ends up with a single override."""
calls = self._record_calls(driver, []) calls = self._record_calls(driver, [])
driver.sync_dns_zone("office.local", [ driver.sync_dns_zone(
{"hostname": "opnsense-office", "ip": "10.10.0.1", "record_type": "A", "enabled": True}, "office.local",
{"hostname": "opnsense-office", "ip": "10.10.0.1", "record_type": "A", "enabled": True}, [
]) {
"hostname": "opnsense-office",
"ip": "10.10.0.1",
"record_type": "A",
"enabled": True,
},
{
"hostname": "opnsense-office",
"ip": "10.10.0.1",
"record_type": "A",
"enabled": True,
},
],
)
added = [d for p, d in calls if p.endswith("addhostoverride")] added = [d for p, d in calls if p.endswith("addhostoverride")]
assert len(added) == 1 assert len(added) == 1
def test_does_not_copy_an_override_made_by_hand(self, driver):
"""The records netOrk sends include what its poll read off this very
device, hand-made overrides among them. Adding those again under the
tag left every one of them twice on gw-01 and gw-02 (NetOrk/netork#1123)."""
rows = [_override_row("by-hand", host="nas", ip="172.22.40.8", description="")]
calls = self._record_calls(driver, rows)
driver.sync_dns_zone(
"office.local",
[
{"hostname": "nas", "ip": "172.22.40.8", "record_type": "A", "enabled": True},
],
)
assert [p for p, _ in calls if p.endswith("addhostoverride")] == []
assert [p for p, _ in calls if "delhostoverride" in p] == []
def test_clears_a_copy_it_once_made_of_a_hand_made_override(self, driver):
"""Devices that already carry the copy lose it on the next sync."""
rows = [
_override_row("by-hand", host="NAS", ip="172.22.40.8", description="NAS"),
_override_row("copy", host="nas", ip="172.22.40.8"),
]
calls = self._record_calls(driver, rows)
driver.sync_dns_zone(
"office.local",
[
{"hostname": "nas", "ip": "172.22.40.8", "record_type": "A", "enabled": True},
{"hostname": "NAS", "ip": "172.22.40.8", "record_type": "A", "enabled": True},
],
)
assert [p.rsplit("/", 1)[-1] for p, _ in calls if "delhostoverride" in p] == ["copy"]
assert [p for p, _ in calls if p.endswith("addhostoverride")] == []
def test_still_adds_a_record_that_differs_from_the_hand_made_one(self, driver):
rows = [_override_row("by-hand", host="nas", ip="172.22.40.8", description="")]
calls = self._record_calls(driver, rows)
driver.sync_dns_zone(
"office.local",
[
{"hostname": "nas", "ip": "172.22.40.9", "record_type": "A", "enabled": True},
],
)
added = [d for p, d in calls if p.endswith("addhostoverride")]
assert [d["host"]["server"] for d in added] == ["172.22.40.9"]
def test_a_disabled_hand_made_override_does_not_stand_in(self, driver):
"""It answers nothing, so the record still has to be added."""
rows = [_override_row("by-hand", host="nas", ip="172.22.40.8", description="", enabled="0")]
calls = self._record_calls(driver, rows)
driver.sync_dns_zone(
"office.local",
[
{"hostname": "nas", "ip": "172.22.40.8", "record_type": "A", "enabled": True},
],
)
assert len([p for p, _ in calls if p.endswith("addhostoverride")]) == 1
def test_a_hand_made_override_in_another_zone_does_not_stand_in(self, driver):
rows = [
_override_row(
"by-hand", host="nas", domain="other.local", ip="172.22.40.8", description=""
)
]
calls = self._record_calls(driver, rows)
driver.sync_dns_zone(
"office.local",
[
{"hostname": "nas", "ip": "172.22.40.8", "record_type": "A", "enabled": True},
],
)
assert len([p for p, _ in calls if p.endswith("addhostoverride")]) == 1
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# uninstall_package – says what it cannot do # uninstall_package – says what it cannot do
+145
View File
@@ -0,0 +1,145 @@
"""Kea's HA peers and the firewall's configuration sync, as facts (netOrk #1125).
The fixture is what gw-01 and gw-02 answered on 2026-10-16: Kea in hot standby
(gw primary, gw-02 standby) and gw-01 pushing its configuration, Kea and
Unbound included, to gw-02.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from napalm_opnsense.ha import config_sync, dhcp_ha
from napalm_opnsense.opnsense import OPNsenseDriver
PAIR = json.loads((Path(__file__).parent / "fixtures" / "ha_pair.json").read_text())
@pytest.fixture
def driver() -> OPNsenseDriver:
d = OPNsenseDriver("gw-01", "user", "secret")
d._session = MagicMock()
return d
def _answers(node: str):
def _get(path: str):
return {
"/api/kea/dhcpv4/get": PAIR[node]["kea_dhcpv4_get"],
"/api/kea/dhcpv4/searchPeer": PAIR[node]["kea_dhcpv4_searchPeer"],
"/api/core/hasync/get": PAIR[node]["core_hasync_get"],
}[path]
return _get
class TestDhcpHa:
def test_reads_the_peers_with_their_roles(self, driver):
driver._get = _answers("gw-01")
ha = driver.get_dhcp_ha()
assert ha["service"] == "kea-dhcp"
assert ha["enabled"] is True
assert ha["peers"] == [
{"name": "gw", "role": "primary", "url": "http://10.5.100.2:8001/"},
{"name": "gw-02", "role": "standby", "url": "http://10.5.100.3:8001/"},
]
def test_names_this_server_from_the_effective_value(self, driver):
"""`this_server_name` is empty when left at its default -- the
hostname; the effective name is in its `%` twin."""
driver._get = _answers("gw-02")
assert driver.get_dhcp_ha()["this_server"] == "gw-02"
def test_an_explicit_server_name_wins(self):
ha = dhcp_ha(
{
"dhcpv4": {
"ha": {"enabled": "1", "this_server_name": "left", "%this_server_name": "x"}
}
},
{"rows": []},
)
assert ha["this_server"] == "left"
def test_ha_switched_off(self):
ha = dhcp_ha({"dhcpv4": {"ha": {"enabled": "0", "this_server_name": ""}}}, {"rows": []})
assert ha["enabled"] is False
assert ha["peers"] == []
assert "this_server" not in ha
def test_a_peer_without_url_has_no_url_key(self):
ha = dhcp_ha(
{"dhcpv4": {"ha": {"enabled": "1"}}},
{"rows": [{"name": "a", "role": "primary", "url": ""}]},
)
assert ha["peers"] == [{"name": "a", "role": "primary"}]
def test_a_failed_read_raises_and_never_says_off(self, driver):
driver._get = MagicMock(side_effect=RuntimeError("timeout"))
with pytest.raises(RuntimeError):
driver.get_dhcp_ha()
def test_an_answer_without_the_ha_section_raises(self):
with pytest.raises(ValueError):
dhcp_ha({"dhcpv4": {}}, {"rows": []})
class TestConfigSync:
def test_the_pushing_side_names_its_peer_and_services(self, driver):
driver._get = _answers("gw-01")
sync = driver.get_config_sync()
assert sync["peer"] == "10.5.100.3"
assert {"kea-dhcp", "unbound", "ntpd", "openssh", "wireguard"} <= set(sync["services"])
assert sync["services"] == sorted(sync["services"])
def test_items_without_a_service_stay_visible(self, driver):
driver._get = _answers("gw-01")
unmapped = driver.get_config_sync()["unmapped"]
assert {"aliases", "rules", "virtualip"} <= set(unmapped)
assert "kea" not in unmapped
def test_only_selected_items_count(self, driver):
driver._get = _answers("gw-01")
sync = driver.get_config_sync()
assert "dnsmasq" not in sync["services"] # dnsforwarder: not selected
assert "cron" not in sync["unmapped"] and "cron" not in sync["services"]
def test_the_receiving_side_pushes_to_no_one(self, driver):
driver._get = _answers("gw-02")
sync = driver.get_config_sync()
assert sync == {"peer": None, "services": [], "unmapped": []}
def test_without_a_peer_selected_items_are_not_pushed(self):
"""Ticked items without a target address push nothing."""
answer = {
"hasync": {"synchronizetoip": "", "syncitems": {"kea": {"value": "Kea", "selected": 1}}}
}
assert config_sync(answer) == {"peer": None, "services": [], "unmapped": []}
def test_a_failed_read_raises(self, driver):
driver._get = MagicMock(side_effect=RuntimeError("timeout"))
with pytest.raises(RuntimeError):
driver.get_config_sync()
def test_an_answer_without_the_section_raises(self):
with pytest.raises(ValueError):
config_sync({"something": {}})
@@ -0,0 +1,132 @@
"""What the firewall is called, which version it runs, and which addresses it serves.
``/api/core/system/status`` on OPNsense 26.7 carries neither name nor version
(``metadata`` and ``subsystems`` only), so every firewall read as nameless and
"unknown". ``/api/diagnostics/system/system_information`` has both: ``name`` is
the FQDN, ``versions[0]`` reads ``"OPNsense 26.7.7-amd64"``.
An address bound to a CARP VHID sits on the interface of every node, in state
MASTER or BACKUP. Only the MASTER answers for it, so only there is it one of the
node's addresses -- the same as keepalived, where only the master has the address
at all (netOrk #1121: a virtual IP's badge sits on its holder's interface).
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from napalm_opnsense.opnsense import OPNsenseDriver
OVERVIEW = json.loads(
(Path(__file__).parent / "fixtures" / "interfaces_overview_carp.json").read_text()
)
SYSTEM_INFORMATION = {
"name": "gw-02.home.manivong.de",
"versions": ["OPNsense 26.7.7-amd64", "FreeBSD 15.1-RELEASE-p4", "OpenSSL 3.5.9"],
}
STATUS_26_7 = {"metadata": {"translations": {}}, "subsystems": {}}
@pytest.fixture
def driver() -> OPNsenseDriver:
with patch("napalm_opnsense.opnsense.requests.Session"):
return OPNsenseDriver(hostname="fw", username="k", password="s")
def _answers(driver, system_information=SYSTEM_INFORMATION, status=STATUS_26_7, overview=OVERVIEW):
def get(path: str):
if path == "/api/diagnostics/system/system_information":
if isinstance(system_information, Exception):
raise system_information
return system_information
if path == "/api/core/system/status":
return status
if path == "/api/interfaces/overview/export":
return overview
raise AssertionError(f"unexpected GET {path}")
driver._get = get
return driver
class TestIdentity:
def test_the_name_is_the_short_host_and_the_fqdn_the_whole(self, driver):
facts = _answers(driver).get_facts()
assert (facts["hostname"], facts["fqdn"]) == ("gw-02", "gw-02.home.manivong.de")
def test_the_version_comes_without_the_architecture(self, driver):
assert _answers(driver).get_facts()["os_version"] == "26.7.7"
@pytest.mark.parametrize(
"line, version",
[
("OPNsense 26.7.7_1-aarch64", "26.7.7_1"),
("OPNsense 25.1", "25.1"),
("OPNsense 24.7.12-i386", "24.7.12"),
],
)
def test_version_spellings(self, driver, line, version):
info = {"name": "fw", "versions": [line]}
assert _answers(driver, system_information=info).get_facts()["os_version"] == version
def test_a_name_without_a_domain_is_both(self, driver):
facts = _answers(driver, system_information={"name": "fw", "versions": []}).get_facts()
assert (facts["hostname"], facts["fqdn"]) == ("fw", "fw")
def test_without_system_information_the_status_answer_still_counts(self, driver):
"""An older OPNsense, or an API key without the diagnostics privilege."""
status = {"hostname": "opnsense01", "version": "24.7"}
facts = _answers(driver, system_information=RuntimeError("403"), status=status).get_facts()
assert (facts["hostname"], facts["os_version"]) == ("opnsense01", "24.7")
def test_nothing_known_is_empty_and_unknown(self, driver):
facts = _answers(driver, system_information={}).get_facts()
assert (facts["hostname"], facts["fqdn"], facts["os_version"]) == ("", "", "unknown")
class TestHeldAddresses:
def test_the_master_serves_its_carp_addresses(self, driver):
result = _answers(driver).get_interfaces_ip()
assert set(result["em0"]["ipv4"]) == {"192.0.2.2", "192.0.2.1"}
assert result["em0"]["ipv4"]["192.0.2.1"] == {"prefix_length": 24}
assert set(result["vlan0040"]["ipv4"]) == {
"198.51.100.2",
"198.51.100.1",
"198.51.100.140",
}
def test_a_backup_serves_its_own_address_only(self, driver):
backup = json.loads(json.dumps(OVERVIEW).replace('"MASTER"', '"BACKUP"'))
result = _answers(driver, overview=backup).get_interfaces_ip()
assert set(result["em0"]["ipv4"]) == {"192.0.2.2"}
assert set(result["vlan0040"]["ipv4"]) == {"198.51.100.2"}
def test_a_vhid_that_is_not_up_is_not_served(self, driver):
result = _answers(driver).get_interfaces_ip()
assert set(result["vlan0011"]["ipv4"]) == {"203.0.113.2"}
def test_a_held_ipv6_carp_address_is_served(self, driver):
result = _answers(driver).get_interfaces_ip()
assert "2001:db8:8::1" in result["em0"]["ipv6"]
def test_an_address_without_a_vhid_is_left_as_before(self, driver):
"""Only CARP addresses are added; a plain alias is a separate question."""
result = _answers(driver).get_interfaces_ip()
assert set(result["vlan0020"]["ipv4"]) == {"192.0.2.130"}
def test_without_a_primary_address_the_list_still_leaves_out_a_backup(self, driver):
overview = [
{
"device": "vlan0099",
"ipv4": [
{"ipaddr": "192.0.2.66/26"},
{"ipaddr": "192.0.2.65/26", "vhid": "199", "status": "BACKUP"},
],
}
]
result = _answers(driver, overview=overview).get_interfaces_ip()
assert set(result["vlan0099"]["ipv4"]) == {"192.0.2.66"}
+147
View File
@@ -0,0 +1,147 @@
"""What listens on an OPNsense firewall, and which OPNsense service it is (netOrk #673).
OPNsense answers through its API, ``diagnostics/interface/get_socket_statistics``:
netstat's sockets with sockstat's user, command and PID merged in, read by configd
as root. A socket that listens has no peer (``*:*``). Which service holds it comes
from the command -- cut to ten characters by FreeBSD -- matched against the
firewall's own service list; WireGuard's sockets belong to the kernel and are
told by their port.
The fixture is a real OPNsense 26.7.5 firewall's answer, cut down, with
documentation addresses.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from napalm_opnsense.opnsense import OPNsenseDriver
STATISTICS = json.loads((Path(__file__).parent / "fixtures" / "socket_statistics.json").read_text())
SERVICES = [
"acme", "captiveportal", "configd", "cron", "crowdsec", "frr", "kea-dhcp", "lldpd",
"login", "mdns-repeater", "ntpd", "openssh", "pf", "snmpd", "udpbroadcastrelay",
"unbound", "webgui", "wireguard", "zabbix_agentd",
] # fmt: skip
WIREGUARD = {
"rows": [
{"if": "wg1", "type": "interface", "listen-port": "51821", "status": "up"},
{"if": "wg1", "type": "peer", "allowed-ips": "10.99.99.2/32"},
{"if": "wg2", "type": "interface", "listen-port": "29281", "status": "up"},
]
}
def _driver(services=SERVICES, wireguard=WIREGUARD) -> OPNsenseDriver:
with patch("napalm_opnsense.opnsense.requests.Session"):
drv = OPNsenseDriver(hostname="fw", username="k", password="s")
drv.session = MagicMock()
def get(path: str) -> dict:
if path == "/api/diagnostics/interface/get_socket_statistics":
return STATISTICS
if path == "/api/core/service/search":
return {"rows": [{"id": n, "name": n, "running": 1} for n in services]}
if path == "/api/wireguard/service/show":
if isinstance(wireguard, Exception):
raise wireguard
return wireguard
raise AssertionError(f"unexpected GET {path}")
drv._get = get # type: ignore[method-assign]
return drv
def _sockets(drv: OPNsenseDriver | None = None) -> dict:
reading = (drv or _driver()).get_listening_sockets()
return {(s["proto"], s["port"], s["address"]): s for s in reading["sockets"]}
class TestTheReading:
def test_read_as_root(self):
assert _driver().get_listening_sockets()["attributed"] is True
def test_a_socket_comes_with_its_process_and_service(self):
assert _sockets()[("tcp", 8080, "192.0.2.1")] == {
"proto": "tcp",
"address": "192.0.2.1",
"port": 8080,
"interface": None,
"process": "crowdsec",
"pid": 90423,
"unit": "crowdsec",
"container_id": None,
}
def test_only_what_listens(self):
"""A connection has a peer; a socket without a port listens on nothing."""
sockets = _driver().get_listening_sockets()["sockets"]
assert len(sockets) == 25
assert all(s["port"] > 0 for s in sockets)
@pytest.mark.parametrize(
"proto, port, address",
[
("tcp", 8000, "0.0.0.0"),
("tcp", 8000, "::"),
("udp", 123, "0.0.0.0"),
("udp", 123, "::"),
],
)
def test_every_address_of_a_family_is_its_any_address(self, proto, port, address):
assert (proto, port, address) in _sockets()
def test_sorted_by_protocol_port_and_address(self):
sockets = _driver().get_listening_sockets()["sockets"]
keys = [(s["proto"], s["port"], s["address"]) for s in sockets]
assert keys == sorted(keys)
class TestWhichService:
@pytest.mark.parametrize(
"proto, port, address, service",
[
("tcp", 22, "192.0.2.1", "openssh"),
("tcp", 443, "192.0.2.1", "webgui"),
("tcp", 8000, "0.0.0.0", "captiveportal"), # lighttpd as www
("udp", 67, "192.0.2.1", "kea-dhcp"), # kea-dhcp4
("tcp", 8000, "127.0.0.1", "kea-dhcp"), # kea-ctrl-agent, cut short
("tcp", 2604, "127.0.0.1", "frr"), # ospfd
("udp", 5353, "198.51.100.1", "mdns-repeater"), # cut short
("udp", 1900, "0.0.0.0", "udpbroadcastrelay"),
("tcp", 10050, "192.0.2.1", "zabbix_agentd"),
("udp", 53, "203.0.113.1", "unbound"),
("udp", 123, "0.0.0.0", "ntpd"),
],
)
def test_by_its_command(self, proto, port, address, service):
assert _sockets()[(proto, port, address)]["unit"] == service
def test_the_command_stays_as_reported(self):
assert _sockets()[("udp", 5353, "198.51.100.1")]["process"] == "mdns-repea"
def test_a_service_the_firewall_does_not_list_is_none(self):
without_snmpd = [n for n in SERVICES if n != "snmpd"]
assert _sockets(_driver(services=without_snmpd))[("udp", 161, "192.0.2.1")]["unit"] is None
def test_wireguard_s_kernel_sockets_by_their_port(self):
wg = _sockets()[("udp", 51821, "0.0.0.0")]
assert (wg["process"], wg["pid"], wg["unit"]) == (None, None, "wireguard")
def test_without_wireguard_a_kernel_socket_is_nobody_s(self):
sockets = _sockets(_driver(wireguard=RuntimeError("404")))
assert sockets[("udp", 29281, "0.0.0.0")]["unit"] is None
def test_netork_finds_it():
"""netOrk asks ``hasattr(driver, "get_listening_sockets")``."""
assert hasattr(OPNsenseDriver, "get_listening_sockets")
+7 -7
View File
@@ -6,11 +6,11 @@ has to be stopped and removed again. These tests drive that whole lifecycle
against a fake ``_get`` / ``_post``. against a fake ``_get`` / ``_post``.
""" """
import pytest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from napalm_opnsense.opnsense import OPNsenseDriver import pytest
from napalm_opnsense.opnsense import OPNsenseDriver
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Fake API # Fake API
@@ -28,11 +28,11 @@ class FakePingAPI:
#: to assume one, which is why nothing caught the payload going to the #: to assume one, which is why nothing caught the payload going to the
#: wrong node. #: wrong node.
self.model_path = tuple(model_path) self.model_path = tuple(model_path)
self.created = [] # payloads passed to set self.created = [] # payloads passed to set
self.started = [] # job ids passed to start self.started = [] # job ids passed to start
self.stopped = [] # job ids passed to stop self.stopped = [] # job ids passed to stop
self.removed = [] # job ids passed to remove self.removed = [] # job ids passed to remove
self.jobs = {} # job id -> hostname self.jobs = {} # job id -> hostname
self.search_calls = 0 self.search_calls = 0
self._next_id = 0 self._next_id = 0
+13 -3
View File
@@ -112,7 +112,10 @@ class TestWhereItGoes:
assert entry["internal_ip"] == "10.0.0.9" assert entry["internal_ip"] == "10.0.0.9"
def test_an_alias_with_two_hosts_is_two_forwards(self): def test_an_alias_with_two_hosts_is_two_forwards(self):
assert [e["internal_ip"] for e in _read(_rule(target="web_pair"))] == ["10.0.0.5", "10.0.0.6"] assert [e["internal_ip"] for e in _read(_rule(target="web_pair"))] == [
"10.0.0.5",
"10.0.0.6",
]
def test_an_alias_that_names_a_host_by_dns_is_skipped(self): def test_an_alias_that_names_a_host_by_dns_is_skipped(self):
"""No address to put the forward on; guessing one would be worse.""" """No address to put the forward on; guessing one would be worse."""
@@ -129,7 +132,14 @@ class TestWhereItGoes:
class TestPorts: class TestPorts:
@pytest.mark.parametrize( @pytest.mark.parametrize(
("value", "expected"), ("value", "expected"),
[("443", 443), ("https", 443), ("http", 80), ("postgres", 5432), ("8000-8010", 8000), ("8000:8010", 8000)], [
("443", 443),
("https", 443),
("http", 80),
("postgres", 5432),
("8000-8010", 8000),
("8000:8010", 8000),
],
) )
def test_the_external_port(self, value, expected): def test_the_external_port(self, value, expected):
(entry,) = _read(_rule(**{"destination.port": value})) (entry,) = _read(_rule(**{"destination.port": value}))
@@ -173,7 +183,7 @@ class TestNameAndSource:
assert "remote_host" not in entry assert "remote_host" not in entry
def test_an_inverted_source_has_no_remote_host(self): def test_an_inverted_source_has_no_remote_host(self):
""""Everyone but X" is as good as anyone for whether it is reachable.""" """ "Everyone but X" is as good as anyone for whether it is reachable."""
(entry,) = _read(_rule(**{"source.network": "203.0.113.7", "source.not": "1"})) (entry,) = _read(_rule(**{"source.network": "203.0.113.7", "source.not": "1"}))
assert "remote_host" not in entry assert "remote_host" not in entry
+210
View File
@@ -0,0 +1,210 @@
"""What netOrk reached through ``_get``/``_post``, as public methods (netOrk #1113).
netOrk followed the firmware upgrade, edited Unbound's forwards and read FRR's
OSPF state through this driver's private REST helpers. The contracts are
napalm-device-types 4.17's; the OPNsense API calls and FRR's field names stay
here.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from napalm_opnsense.opnsense import OPNsenseDriver
@pytest.fixture
def driver():
with patch("napalm_opnsense.opnsense.requests.Session"):
drv = OPNsenseDriver(
hostname="fw", username="k", password="s", optional_args={"verify": False}
)
drv.session = MagicMock()
yield drv
class TestUpdateProgress:
@pytest.mark.parametrize(
"status, state",
[
("running", "running"),
("done", "done"),
("error", "failed"),
("", "idle"),
(None, "idle"),
],
)
def test_the_upgrade_status_as_a_state(self, driver, status, state):
st = {"status": status, "log": "***GOT REQUEST TO UPGRADE***\nfetching\n"}
with patch.object(driver, "_get", return_value=st) as get:
progress = driver.get_update_progress()
get.assert_called_once_with("/api/core/firmware/upgradestatus")
assert progress == {"state": state, "log": "***GOT REQUEST TO UPGRADE***\nfetching\n"}
def test_the_packages_go_with_the_upgrade(self, driver):
"""netOrk named the packages it upgrades; ``apply_updates`` dropped them."""
with patch.object(driver, "_post", return_value={"status": "ok"}) as post:
result = driver.apply_updates(["opnsense", "openssl"])
post.assert_called_once_with(
"/api/core/firmware/upgrade", {"upgrade_packages": ["opnsense", "openssl"]}
)
assert result["success"] is True
def test_no_packages_is_the_whole_upgrade(self, driver):
with patch.object(driver, "_post", return_value={"status": "ok"}) as post:
driver.apply_updates([])
post.assert_called_once_with("/api/core/firmware/upgrade")
ROWS = [
{
"uuid": "u1",
"enabled": "1",
"type": "forward",
"domain": "lab.example",
"server": "10.0.0.53",
"port": "53",
},
{
"uuid": "u2",
"enabled": "0",
"type": "forward",
"domain": "old.example",
"server": "10.0.0.9",
"port": "",
},
{"uuid": "u3", "enabled": "1", "type": "dot", "domain": "", "server": "9.9.9.9", "port": "853"},
]
class TestDnsForwards:
def _post(self, calls):
def post(path, data=None):
calls.append((path, data))
if path.endswith("searchForward"):
return {"rows": ROWS}
return {"result": "saved", "status": "ok"}
return post
def test_the_forwards_without_dns_over_tls(self, driver):
calls = []
with patch.object(driver, "_post", side_effect=self._post(calls)):
forwards = driver.get_dns_forwards()
assert forwards == [
{"domain": "lab.example", "server": "10.0.0.53", "port": 53, "enabled": True},
{"domain": "old.example", "server": "10.0.0.9", "port": 53, "enabled": False},
]
def test_a_new_domain_is_added_and_applied(self, driver):
calls = []
with patch.object(driver, "_post", side_effect=self._post(calls)):
driver.set_dns_forward("new.example.", "10.0.0.53")
paths = [p for p, _ in calls]
assert paths[-2:] == [
"/api/unbound/settings/addForward",
"/api/unbound/service/reconfigure",
]
added = calls[-2][1]["dot"]
assert added == {
"enabled": "1",
"type": "forward",
"domain": "new.example",
"server": "10.0.0.53",
"port": "53",
}
def test_a_known_domain_is_changed_not_doubled(self, driver):
calls = []
with patch.object(driver, "_post", side_effect=self._post(calls)):
driver.set_dns_forward("lab.example", "10.0.0.54", port=5353)
paths = [p for p, _ in calls]
assert "/api/unbound/settings/setForward/u1" in paths
assert "/api/unbound/settings/addForward" not in paths
def test_a_refusal_is_raised(self, driver):
def post(path, data=None):
if path.endswith("searchForward"):
return {"rows": []}
return {"result": "failed", "validations": {"dot.server": "invalid"}}
with (
patch.object(driver, "_post", side_effect=post),
pytest.raises(RuntimeError, match="invalid"),
):
driver.set_dns_forward("x.example", "not-an-ip")
def test_removing_deletes_every_forward_of_the_domain(self, driver):
calls = []
with patch.object(driver, "_post", side_effect=self._post(calls)):
driver.remove_dns_forward("lab.example.")
paths = [p for p, _ in calls]
assert paths[-2:] == [
"/api/unbound/settings/delForward/u1",
"/api/unbound/service/reconfigure",
]
def test_removing_an_unknown_domain_changes_nothing(self, driver):
calls = []
with patch.object(driver, "_post", side_effect=self._post(calls)):
driver.remove_dns_forward("nothing.example")
assert [p for p, _ in calls] == ["/api/unbound/settings/searchForward"]
OVERVIEW = {"response": {"routerId": "10.255.0.1", "areas": {"0.0.0.0": {}, "0.0.0.1": {}}}}
INTERFACES = {
"response": {
"interfaces": {
"wg0": {
"ipAddress": "10.255.0.1",
"ipAddressPrefixlen": 24,
"area": "0.0.0.0",
"networkType": "POINTOPOINT",
"authentication": "MessageDigest",
"nbrCount": 2,
"nbrAdjacentCount": 1,
"cost": 10,
}
}
}
}
class TestOspf:
def test_frr_in_neutral_terms(self, driver):
def get(path):
return OVERVIEW if path.endswith("ospfoverview") else INTERFACES
with patch.object(driver, "_get", side_effect=get):
ospf = driver.get_ospf()
assert ospf == {
"router_id": "10.255.0.1",
"areas": ["0.0.0.0", "0.0.0.1"],
"interfaces": {
"wg0": {
"address": "10.255.0.1",
"prefix_length": 24,
"area": "0.0.0.0",
"network_type": "POINTOPOINT",
"authentication": "MessageDigest",
"neighbors": 2,
"neighbors_adjacent": 1,
"cost": 10,
}
},
}
def test_no_ospf_running(self, driver):
with patch.object(driver, "_get", return_value={"response": {}}):
assert driver.get_ospf() == {"router_id": None, "areas": [], "interfaces": {}}
+181
View File
@@ -0,0 +1,181 @@
"""The SNMP agent, set up through napalm-device-types' ``configure_snmp_agent``.
``fix_snmp`` enabled os-net-snmp with community "public" and opened UDP/161 on
every interface to every source (#13). The template's steps take every value
from the caller: community, contact, location, listen address, and the sources
the firewall lets through -- one pass rule each, and the old any-source rule
goes when sources are given.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from napalm_opnsense.opnsense import OPNsenseDriver
SETTINGS = {
"community": "c0mm",
"allowed_sources": ["10.7.224.20", "10.7.225.0/24"],
"contact": "ops",
"location": "rack 1",
}
class _Api:
"""A firewall's REST API: answers by path, records every POST."""
def __init__(self, *, installed=True, rules=(), saved="saved"):
self.installed = installed
self.rules = list(rules)
self.saved = saved
self.posts: list[tuple[str, dict | None]] = []
def get(self, path):
if path == "/api/netsnmp/general/get":
return {"general": {"enabled": "0"}} if self.installed else {}
raise AssertionError(path)
def post(self, path, body=None):
self.posts.append((path, body))
if path == "/api/netsnmp/general/set":
return {"result": self.saved}
if path == "/api/firewall/filter/searchRule":
return {"rows": self.rules}
if path.startswith("/api/firewall/filter/addRule"):
return {"result": "saved", "uuid": "new"}
if path.startswith("/api/firewall/filter/delRule/"):
return {"result": "deleted"}
if path in ("/api/firewall/filter/apply", "/api/netsnmp/service/restart"):
return {"status": "ok"}
if path.startswith("/api/core/firmware/install/"):
return {"status": "ok", "msg_uuid": "x"}
raise AssertionError(path)
def paths(self):
return [p for p, _ in self.posts]
@pytest.fixture
def api():
return _Api()
@pytest.fixture
def driver(api, monkeypatch):
d = OPNsenseDriver("10.0.0.1", "key", "secret")
d._session = MagicMock()
d._get = api.get
d._post = api.post
monkeypatch.setattr("time.sleep", lambda s: None)
return d
def _body(api, path):
return next(b for p, b in api.posts if p == path)
class TestTheCallersValues:
def test_community_contact_location_and_listen(self, driver, api):
result = driver.configure_snmp_agent({**SETTINGS, "listen": "10.0.0.1"})
assert result["success"] is True
general = _body(api, "/api/netsnmp/general/set")["general"]
assert general["enabled"] == "1"
assert (general["community"], general["contact"], general["location"]) == (
"c0mm",
"ops",
"rack 1",
)
assert general["listen"] == {"10.0.0.1": {"selected": 1}}
def test_nothing_of_an_application_is_built_in(self, driver, api):
driver.configure_snmp_agent({"community": "c0mm", "allowed_sources": []})
sent = repr(api.posts)
assert "public" not in sent and "netork" not in sent.lower()
def test_a_dns_name_is_not_a_listen_address(self, api, monkeypatch):
d = OPNsenseDriver("gw-01.lan", "key", "secret")
d._session, d._get, d._post = MagicMock(), api.get, api.post
monkeypatch.setattr("time.sleep", lambda s: None)
d.configure_snmp_agent({"community": "c0mm", "allowed_sources": []})
assert _body(api, "/api/netsnmp/general/set")["general"]["listen"] == {}
def test_a_rejected_configuration_fails_the_fix(self, driver, api):
api.saved = "failed"
result = driver.configure_snmp_agent(SETTINGS)
assert result["success"] is False
assert "/api/netsnmp/service/restart" not in api.paths()
class TestTheFirewall:
def test_one_pass_rule_per_allowed_source(self, driver, api):
driver.configure_snmp_agent(SETTINGS)
rules = [b["rule"] for p, b in api.posts if p == "/api/firewall/filter/addRule"]
assert [r["source_net"] for r in rules] == ["10.7.224.20/32", "10.7.225.0/24"]
assert all(r["destination_port"] == "161" and r["protocol"] == "udp" for r in rules)
assert all("netork" not in r["descr"].lower() for r in rules)
assert "/api/firewall/filter/apply" in api.paths()
def test_the_old_any_source_rule_goes(self, api, driver):
api.rules = [{"uuid": "old-1", "description": "[netork] Allow SNMP", "source_net": "any"}]
driver.configure_snmp_agent(SETTINGS)
assert "/api/firewall/filter/delRule/old-1" in api.paths()
def test_rules_of_an_earlier_run_are_replaced_not_doubled(self, api, driver):
api.rules = [{"uuid": "r1", "description": "napalm-opnsense: SNMP from 10.7.224.20/32"}]
driver.configure_snmp_agent(SETTINGS)
assert "/api/firewall/filter/delRule/r1" in api.paths()
assert sum(p == "/api/firewall/filter/addRule" for p in api.paths()) == 2
def test_an_operators_rule_is_left_alone(self, api, driver):
api.rules = [{"uuid": "mine", "description": "SNMP from the monitoring VLAN"}]
driver.configure_snmp_agent(SETTINGS)
assert not any(p.startswith("/api/firewall/filter/delRule/") for p in api.paths())
def test_no_sources_leave_the_firewall_alone(self, driver, api):
result = driver.configure_snmp_agent({"community": "c0mm", "allowed_sources": []})
assert result["success"] is True
assert not any(p.startswith("/api/firewall/") for p in api.paths())
class TestInstallAndStart:
def test_an_installed_plugin_is_not_reinstalled(self, driver, api):
driver.configure_snmp_agent(SETTINGS)
assert not any(p.startswith("/api/core/firmware/install/") for p in api.paths())
assert api.paths()[-1] == "/api/netsnmp/service/restart"
def test_a_missing_plugin_is_installed_first(self, api, driver):
api.installed = False
driver.configure_snmp_agent(SETTINGS)
assert api.paths()[0] == "/api/core/firmware/install/os-net-snmp"
def test_the_old_action_is_gone():
assert not hasattr(OPNsenseDriver, "run_device_action")
assert not hasattr(OPNsenseDriver, "_action_fix_snmp")
@pytest.mark.parametrize("community", ["two words", 'q"uote', ""])
def test_a_community_that_is_not_one_word_is_refused(driver, api, community):
result = driver.configure_snmp_agent({"community": community, "allowed_sources": []})
assert result["success"] is False
assert "/api/netsnmp/general/set" not in api.paths()
+23
View File
@@ -123,3 +123,26 @@ class TestHostStatus:
def test_no_pending_reboot(self, driver): def test_no_pending_reboot(self, driver):
with patch.object(driver, "_get", return_value=_status()): with patch.object(driver, "_get", return_value=_status()):
assert driver.get_host_status()["reboot_required"] is False assert driver.get_host_status()["reboot_required"] is False
class TestRebootHost:
"""``reboot_host`` restarts the firewall through its firmware API (netOrk #637)."""
def test_the_firmware_api_is_asked(self, driver):
with patch.object(driver, "_post", return_value={"status": "ok"}) as post:
driver.reboot_host()
post.assert_called_once_with("/api/core/firmware/reboot")
def test_a_refusal_is_raised(self, driver):
with patch.object(driver, "_post", side_effect=ConnectionError("403 Forbidden")):
with pytest.raises(RuntimeError, match="403"):
driver.reboot_host()
def test_no_warnings_of_its_own():
"""The pending updates are get_available_updates; the caller names
updates_available from them (netOrk #1111)."""
from napalm_opnsense.opnsense import OPNsenseDriver
assert "get_device_warnings" not in vars(OPNsenseDriver)
+130
View File
@@ -0,0 +1,130 @@
"""The firewall's CARP addresses, with who holds each (netOrk #1121).
``/api/interfaces/overview/export`` -- the source ``get_interfaces_ip`` reads --
lists every address on an interface, and an address bound to a CARP VHID carries
the VHID and this node's CARP state. That covers both the CARP address itself and
an IP alias bound to its VHID: both float with the VHID's MASTER. An address
without a VHID stays on this node and is no virtual IP.
The overview has no CARP password in it; ``interfaces/vip_settings`` would, which
is why it is not read. The fixture is the shape of a real OPNsense 26.7 answer,
cut down, with documentation addresses.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from napalm_opnsense.opnsense import OPNsenseDriver
from napalm_opnsense.virtual_ips import carp_addresses
OVERVIEW = json.loads(
(Path(__file__).parent / "fixtures" / "interfaces_overview_carp.json").read_text()
)
def _driver(overview=OVERVIEW) -> OPNsenseDriver:
with patch("napalm_opnsense.opnsense.requests.Session"):
drv = OPNsenseDriver(hostname="fw", username="k", password="s")
drv.session = MagicMock()
def get(path: str):
if path == "/api/interfaces/overview/export":
if isinstance(overview, Exception):
raise overview
return overview
raise AssertionError(f"unexpected GET {path}")
drv._get = get # type: ignore[method-assign]
return drv
def _by_address(entries) -> dict:
return {e["address"]: e for e in entries}
class TestCarpAddresses:
def test_every_address_bound_to_a_vhid_is_a_virtual_ip(self):
assert [e["address"] for e in carp_addresses(OVERVIEW)] == [
"192.0.2.1",
"198.51.100.1",
"198.51.100.140",
"203.0.113.1",
"2001:db8:8::1",
]
def test_an_address_without_a_vhid_stays_on_this_node(self):
addresses = {e["address"] for e in carp_addresses(OVERVIEW)}
assert not addresses & {"192.0.2.2", "192.0.2.140", "127.0.0.1", "fe80::1"}
def test_the_carp_address_carries_its_interface_and_state(self):
assert _by_address(carp_addresses(OVERVIEW))["192.0.2.1"] == {
"mechanism": "carp",
"instance": "MGMT",
"address": "192.0.2.1",
"prefix_length": 24,
"interface": "em0",
"group_key": "vhid:108",
"running": True,
"holds": True,
}
def test_an_alias_on_a_vhid_follows_that_vhid(self):
alias = _by_address(carp_addresses(OVERVIEW))["198.51.100.140"]
assert (alias["group_key"], alias["prefix_length"], alias["holds"]) == (
"vhid:140",
32,
True,
)
def test_a_vhid_not_up_neither_runs_nor_holds(self):
entry = _by_address(carp_addresses(OVERVIEW))["203.0.113.1"]
assert (entry["running"], entry["holds"]) == (False, False)
def test_an_interface_without_a_description_is_named_by_its_identifier(self):
assert _by_address(carp_addresses(OVERVIEW))["203.0.113.1"]["instance"] == "opt9"
def test_on_a_backup_node_nothing_is_held(self):
backup = json.loads(json.dumps(OVERVIEW).replace('"MASTER"', '"BACKUP"'))
entries = carp_addresses(backup)
assert entries and all(e["holds"] is False for e in entries)
def test_the_answer_wrapped_in_an_object_reads_the_same(self):
assert carp_addresses({"interfaces": OVERVIEW}) == carp_addresses(OVERVIEW)
@pytest.mark.parametrize("answer", [[], {}, {"interfaces": None}])
def test_a_firewall_without_interfaces_has_none(self, answer):
assert carp_addresses(answer) == []
@pytest.mark.parametrize("answer", ["nonsense", None, 42])
def test_an_answer_of_another_shape_is_refused(self, answer):
with pytest.raises(ValueError):
carp_addresses(answer)
def test_odd_entries_are_skipped(self):
overview = [
{"identifier": "opt1", "ipv4": ["192.0.2.9/24", {"vhid": "1"}, None]},
"not-an-interface",
{"identifier": "opt2", "ipv4": [{"ipaddr": "bogus", "vhid": "2"}]},
]
assert carp_addresses(overview) == []
class TestGetVirtualIps:
def test_a_firewall_reports_carp_and_does_not_look_for_the_linux_mechanisms(self):
reading = _driver().get_virtual_ips()
assert reading["vip_manager"] is None
assert reading["keepalived"] is None
assert len(reading["carp"]) == 5
def test_a_firewall_without_carp_reports_an_empty_list(self):
reading = _driver(overview=[OVERVIEW[3], OVERVIEW[4]]).get_virtual_ips()
assert reading["carp"] == []
def test_a_failed_request_is_raised_not_reported_as_none_declared(self):
with pytest.raises(RuntimeError):
_driver(overview=RuntimeError("API down")).get_virtual_ips()