Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63ce6e78bd | ||
|
|
ae732fd7c8 | ||
|
|
6e0e206e06 | ||
|
|
8815c4a584 | ||
|
|
571d9abb5f | ||
|
|
692a584fd2 | ||
|
|
68dbd5891c | ||
|
|
5a6e573300 | ||
|
|
59796b3fa1 | ||
|
|
6a91f43fc3 | ||
|
|
fc4658f016 | ||
|
|
7183da24fb | ||
|
|
5ab33d67f3 | ||
|
|
39cc177bf2 | ||
|
|
c4edfc88e4 | ||
|
|
9fe6245940 | ||
|
|
e3ed0f1bdd | ||
|
|
055ec3da71 | ||
|
|
e334dc0058 | ||
|
|
5a1aa66db4 | ||
|
|
5b54174cc4 | ||
|
|
2af99995e9 | ||
|
|
c868d41322 | ||
|
|
00172f7a69 | ||
|
|
e318c97c20 | ||
|
|
852c1d59df | ||
|
|
fa88850535 | ||
|
|
2fb1226734 | ||
|
|
8ce7ea52e7 | ||
|
|
a6c1d77791 | ||
|
|
b3e89f003c | ||
|
|
fdda745388 | ||
|
|
0f172f02c0 |
No files matched your search
@@ -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 }}
|
||||||
@@ -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/*
|
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
|
}
|
||||||
@@ -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 "")
|
||||||
|
)
|
||||||
+527
-303
File diff suppressed because it is too large.
Load diff
@@ -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()},
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Whitespace-only changes.
@@ -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]
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
+388
-80
@@ -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: (
|
||||||
|
calls.append(path)
|
||||||
|
or {
|
||||||
"result": "failed",
|
"result": "failed",
|
||||||
"validations": {"client.name": "A value is required."},
|
"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: (
|
||||||
|
calls.append(path)
|
||||||
|
or {
|
||||||
"result": "failed",
|
"result": "failed",
|
||||||
"validations": {"user.username": "A value is required."},
|
"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": "10.0.0.0/8", "gateway": "WAN_GW", "descr": "internal"},
|
||||||
{"network": "0.0.0.0/0", "gateway": "WAN_GW", "descr": "default"},
|
{"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
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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": {}}
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Pending updates on OPNsense, read from the firmware status the firewall caches.
|
||||||
|
|
||||||
|
``get_available_updates`` used to trigger ``firmware/check`` and sleep up to
|
||||||
|
15 s; a poll could not afford that, and when the check was not done in time it
|
||||||
|
reported "no updates". Reading now comes from the cached ``firmware/status``,
|
||||||
|
which carries when the firewall last checked. A firewall that never checked, or
|
||||||
|
cannot reach its mirror, raises: netOrk keeps "pending since" per package, and
|
||||||
|
an empty list would mean nothing is pending. ``refresh_available_updates`` runs
|
||||||
|
the check and waits for it (netOrk MVP 5).
|
||||||
|
|
||||||
|
The status fields are the real ones of an OPNsense 26.7.5 firewall.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from napalm_opnsense.opnsense import OPNsenseDriver
|
||||||
|
|
||||||
|
|
||||||
|
def _status(**over) -> dict:
|
||||||
|
status = {
|
||||||
|
"connection": "ok",
|
||||||
|
"repository": "ok",
|
||||||
|
"last_check": "Mon Oct 5 07:37:38 CEST 2026",
|
||||||
|
"needs_reboot": "0",
|
||||||
|
"upgrade_needs_reboot": "0",
|
||||||
|
"status": "none",
|
||||||
|
"status_msg": "There are no updates available on the selected mirror.",
|
||||||
|
"upgrade_packages": [],
|
||||||
|
}
|
||||||
|
status.update(over)
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
PENDING = [
|
||||||
|
{"name": "opnsense", "current_version": "26.7.5", "new_version": "26.7.6", "reason": "upgrade"},
|
||||||
|
{"name": "openssl", "current_version": "3.0.16", "new_version": "3.0.17", "reason": "upgrade"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@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 TestAvailableUpdates:
|
||||||
|
def test_nothing_pending_after_a_check_is_an_empty_list(self, driver):
|
||||||
|
with (
|
||||||
|
patch.object(driver, "_get", return_value=_status()) as get,
|
||||||
|
patch.object(driver, "_post") as post,
|
||||||
|
):
|
||||||
|
assert driver.get_available_updates() == []
|
||||||
|
|
||||||
|
get.assert_called_once_with("/api/core/firmware/status")
|
||||||
|
post.assert_not_called()
|
||||||
|
|
||||||
|
def test_pending_packages_from_the_cached_status(self, driver):
|
||||||
|
status = _status(status="update", upgrade_packages=PENDING)
|
||||||
|
with patch.object(driver, "_get", return_value=status):
|
||||||
|
updates = driver.get_available_updates()
|
||||||
|
|
||||||
|
assert updates == [
|
||||||
|
{"name": "openssl", "current_version": "3.0.16", "new_version": "3.0.17"},
|
||||||
|
{"name": "opnsense", "current_version": "26.7.5", "new_version": "26.7.6"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_a_firewall_that_never_checked_raises(self, driver):
|
||||||
|
with patch.object(driver, "_get", return_value=_status(last_check="")):
|
||||||
|
with pytest.raises(RuntimeError, match="checked"):
|
||||||
|
driver.get_available_updates()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field", ["connection", "repository"])
|
||||||
|
def test_a_mirror_it_cannot_reach_raises(self, driver, field):
|
||||||
|
with patch.object(driver, "_get", return_value=_status(**{field: "error"})):
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
driver.get_available_updates()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefresh:
|
||||||
|
def test_the_check_runs_and_is_waited_for(self, driver):
|
||||||
|
before = _status(last_check="Mon Oct 5 07:37:38 CEST 2026")
|
||||||
|
after = _status(last_check="Tue Oct 6 09:00:01 CEST 2026")
|
||||||
|
with (
|
||||||
|
patch.object(driver, "_get", side_effect=[before, before, after]),
|
||||||
|
patch.object(driver, "_post") as post,
|
||||||
|
patch("napalm_opnsense.opnsense.time.sleep"),
|
||||||
|
):
|
||||||
|
result = driver.refresh_available_updates()
|
||||||
|
|
||||||
|
post.assert_called_once_with("/api/core/firmware/check")
|
||||||
|
assert result["success"] is True
|
||||||
|
|
||||||
|
def test_a_check_that_does_not_finish_in_time_says_so(self, driver):
|
||||||
|
with (
|
||||||
|
patch.object(driver, "_get", return_value=_status()),
|
||||||
|
patch.object(driver, "_post"),
|
||||||
|
patch("napalm_opnsense.opnsense.time.sleep"),
|
||||||
|
):
|
||||||
|
result = driver.refresh_available_updates()
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostStatus:
|
||||||
|
def test_needs_reboot_comes_from_the_firmware_status(self, driver):
|
||||||
|
with patch.object(driver, "_get", return_value=_status(needs_reboot="1")):
|
||||||
|
status = driver.get_host_status()
|
||||||
|
|
||||||
|
assert status == {
|
||||||
|
"reboot_required": True,
|
||||||
|
"reboot_reason": "the firmware status reports a pending reboot",
|
||||||
|
"auto_updates": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_no_pending_reboot(self, driver):
|
||||||
|
with patch.object(driver, "_get", return_value=_status()):
|
||||||
|
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)
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user