Files
napalm-opnsense/napalm_opnsense/ping_mixin.py
T
Christian Manivong 5d193ba7e8
CI / test (3.11) (push) Failing after 7s
CI / test (3.10) (push) Failing after 12s
CI / test (3.9) (push) Failing after 6s
CI / test (3.12) (push) Failing after 18s
fix(ping): post the settings where the API expects them, not one node above
Every ping against a live OPNsense failed:

    ping job creation failed for 10.30.0.1: {'result': 'failed',
    'validations': {'ping.settings.hostname': 'A value is required.'}}

_ping_model_node read GET /api/diagnostics/ping/get and took the single
dict-valued key as the node to post under. The real model nests two levels:

    {"ping": {"settings": {"hostname": "", "fam": {"ip": {...}, "ip6": {...}},
                           "source_address": "", "packetsize": "", ...}}}

so the helper answered "ping" and the job was created with the fields sitting
where the settings node belongs. The hostname never arrived, and the firewall
said so on every single call.

_ping_model_path walks the whole chain of single-dict wrappers and stops at the
first level holding more than one key — the field level, where fam is a dict
too and one more step would land inside a form field. _wrap_in_model nests the
settings accordingly, so a one-level model keeps working and the default, for
when /get cannot be read, is what current firmware ships.

The tests missed this because FakePingAPI answered /get with a one-level model
and read the posted payload back through the same assumption: the fake agreed
with the code about a shape neither of them shares with a device. It now speaks
what an OPNsense speaks, reads the payload through the model path, and a second
test keeps the one-level case covered.

Verified against a live firewall: the job is accepted ({"result": "ok"}) and
10.30.0.1 answers 3 of 3 at 0.116 ms.

Closes #3
2026-08-22 15:44:56 +07:00

386 lines
16 KiB
Python

# -*- coding: utf-8 -*-
# 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.
"""Ping support for OPNsense — NAPALM ``ping()`` plus a batched ``ping_sweep()``.
OPNsense has no synchronous "ping once and tell me the result" endpoint.
``/api/diagnostics/ping`` is a *job* API: a job is created (``set``), started
(``start``), then runs in the background writing statistics that are read back
via ``search_jobs``, and finally has to be stopped and removed again. A single
ping therefore costs five API calls and roughly one second of waiting.
That makes the generic sequential sweep in ``napalm-device-types`` far too slow
here, but it also hands us something better: jobs are independent and run on
the firewall in parallel, and ``search_jobs`` reports *all* of them in one
response. :meth:`OPNsensePingMixin.ping_sweep` therefore works in batches —
create and start a whole batch, wait once, read every result with a single
request, then clean the batch up. Cost per batch is constant in waiting time
instead of linear in hosts.
``PING_SWEEP_BATCH_SIZE`` bounds how many ping processes the firewall is asked
to run at once; ``PING_SWEEP_MAX_TARGETS`` bounds the sweep as a whole. Both
are deliberately conservative — this runs on production firewalls.
Why the results are *polled* rather than read once: ``search_jobs`` signals the
running ping with ``SIGINFO`` and then parses whatever the process has written
to its log so far. The statistics line therefore lands in the log slightly
after the request that triggered it, so the first read of a healthy host can
still show zero probes. Every job is asked repeatedly until it reports the
requested probe count or the time budget runs out.
Not supported by the API and therefore ignored: ``ttl``, ``vrf``.
"""
from __future__ import annotations
import logging
import time
from ipaddress import ip_address
from typing import Any, Callable, Iterable, Optional
logger = logging.getLogger(__name__)
_PING_API = "/api/diagnostics/ping"
#: Where the ping fields live when the model cannot be read. Matches what
#: OPNsense 24/25 ship: {"ping": {"settings": {...}}}.
_DEFAULT_PING_MODEL_PATH = ("ping", "settings")
#: Seconds between two ``search_jobs`` polls while waiting for probe results.
_POLL_INTERVAL = 0.5
#: The API's ``interval`` field is in whole seconds and has a minimum of 1,
#: so one probe takes at least this long.
_PROBE_INTERVAL_SECONDS = 1
#: Extra grace on top of the expected probe duration before giving up on a job.
_POLL_GRACE_SECONDS = 1.0
class OPNsensePingMixin:
"""NAPALM ``ping()`` / ``ping_sweep()`` on the OPNsense diagnostics ping API."""
#: Ping jobs started on the firewall at the same time.
PING_SWEEP_BATCH_SIZE: int = 32
#: Hosts probed in a single sweep. A /24 fits; a /16 must be split by
#: the caller — 65k background jobs is not something to point at a firewall.
PING_SWEEP_MAX_TARGETS: int = 512
# Provided by the driver this mixin is mixed into.
_ping_model_path_cache: Optional[tuple[str, ...]] = None
# ── NAPALM ───────────────────────────────────────────────────────────────
def ping(
self,
destination: str,
source: str = "",
ttl: int = 255,
timeout: int = 2,
size: int = 100,
count: int = 5,
vrf: str = "",
) -> dict[str, Any]:
"""Ping *destination* from the firewall.
Runs the full job lifecycle and returns NAPALM's standard ping shape:
``{"success": {...}}`` with probe counts and RTTs, or ``{"error": ...}``
if the job could not be created or its statistics could not be read.
"""
try:
job_id = self._ping_job_create(destination, source=source, size=size)
except Exception as exc: # noqa: BLE001 - reported to the caller as-is
return {"error": str(exc)}
try:
self._post(f"{_PING_API}/start/{job_id}", {})
rows = self._await_ping_rows([job_id], count=count, timeout=timeout)
except Exception as exc: # noqa: BLE001
return {"error": str(exc)}
finally:
self._ping_job_cleanup([job_id])
return self._row_to_napalm_reply(destination, rows.get(job_id), count=count)
def ping_sweep(
self,
destinations: Iterable[str],
*,
count: int = 1,
timeout: int = 1,
max_targets: Optional[int] = None,
on_progress: Optional[Callable[[int, int], None]] = None,
should_stop: Optional[Callable[[], bool]] = None,
) -> dict[str, Any]:
"""Ping many destinations, one batch of parallel firewall jobs at a time.
Same contract as
:meth:`napalm_device_types.ping_sweep.PingSweepMixin.ping_sweep`;
``should_stop`` is polled between batches rather than between hosts,
because a batch is the smallest unit of work here.
"""
limit = self.PING_SWEEP_MAX_TARGETS if max_targets is None else max_targets
targets = list(destinations)
truncated = len(targets) > limit
if truncated:
targets = targets[:limit]
total = len(targets)
entries: list[dict[str, Any]] = []
for start in range(0, total, self.PING_SWEEP_BATCH_SIZE):
if should_stop is not None and should_stop():
break
batch = targets[start : start + self.PING_SWEEP_BATCH_SIZE]
entries.extend(self._sweep_batch(batch, count=count, timeout=timeout))
if on_progress is not None:
on_progress(len(entries), total)
return {
"entries": entries,
"scanned": len(entries),
"alive_count": sum(1 for entry in entries if entry["alive"]),
"truncated": truncated,
}
# ── Batch execution ──────────────────────────────────────────────────────
def _sweep_batch(
self, destinations: list[str], *, count: int, timeout: int
) -> list[dict[str, Any]]:
"""Create, start, harvest and remove one batch of ping jobs."""
job_ids: dict[str, str] = {} # destination -> job id
errors: dict[str, str] = {} # destination -> creation error
for destination in destinations:
try:
job_ids[destination] = self._ping_job_create(destination)
except Exception as exc: # noqa: BLE001 - one bad host must not kill the batch
errors[destination] = str(exc)
rows: dict[str, dict[str, Any]] = {}
batch_error: str | None = None
try:
for job_id in job_ids.values():
self._post(f"{_PING_API}/start/{job_id}", {})
rows = self._await_ping_rows(list(job_ids.values()), count=count, timeout=timeout)
except Exception as exc: # noqa: BLE001 - the whole batch loses its results
batch_error = str(exc)
finally:
self._ping_job_cleanup(list(job_ids.values()))
entries: list[dict[str, Any]] = []
for destination in destinations:
if destination in errors:
entries.append(self._parse_ping_reply(destination, {"error": errors[destination]}))
elif batch_error is not None:
entries.append(self._parse_ping_reply(destination, {"error": batch_error}))
else:
reply = self._row_to_napalm_reply(
destination, rows.get(job_ids[destination]), count=count
)
entries.append(self._parse_ping_reply(destination, reply))
return entries
# ── Job lifecycle ────────────────────────────────────────────────────────
def _ping_job_create(self, destination: str, *, source: str = "", size: int = 100) -> str:
"""Create a ping job for *destination* and return its job id."""
settings = {
"hostname": destination,
"fam": self._address_family(destination),
"packetsize": str(size),
"interval": str(_PROBE_INTERVAL_SECONDS),
"description": "netork ping sweep",
}
if source:
settings["source_address"] = source
response = self._post(f"{_PING_API}/set", self._wrap_in_model(settings))
job_id = response.get("uuid") or response.get("id")
if not job_id:
raise RuntimeError(f"ping job creation failed for {destination}: {response}")
return str(job_id)
def _ping_job_cleanup(self, job_ids: list[str]) -> None:
"""Stop and remove *job_ids*, never raising — cleanup is best effort.
Every job left behind keeps a ping process and a log file in ``/tmp/ping``
on the firewall, so this runs even when the sweep itself failed.
"""
for job_id in job_ids:
for action in ("stop", "remove"):
try:
self._post(f"{_PING_API}/{action}/{job_id}", {})
except Exception as exc: # noqa: BLE001
logger.debug("ping job %s: %s failed: %s", job_id, action, exc)
def _await_ping_rows(
self, job_ids: list[str], *, count: int, timeout: int
) -> dict[str, dict[str, Any]]:
"""Poll ``search_jobs`` until every job sent *count* probes, or time is up.
Returns the last rows seen, keyed by job id — a job that never reported
is simply absent, which the caller reads as "no answer".
"""
if not job_ids:
return {}
wanted = set(job_ids)
budget = max(timeout, 1) * max(count, 1) + _POLL_GRACE_SECONDS
attempts = max(1, int(budget / _POLL_INTERVAL))
rows: dict[str, dict[str, Any]] = {}
for attempt in range(attempts):
if attempt:
time.sleep(_POLL_INTERVAL)
rows = {
job_id: row
for job_id, row in self._read_ping_jobs().items()
if job_id in wanted
}
if wanted and all(
_as_int(rows.get(job_id, {}).get("send")) >= count for job_id in wanted
):
break
return rows
def _read_ping_jobs(self) -> dict[str, dict[str, Any]]:
"""All ping jobs currently known to the firewall, keyed by job id."""
response = self._get(f"{_PING_API}/search_jobs")
rows = response.get("rows") or []
result: dict[str, dict[str, Any]] = {}
for row in rows:
job_id = row.get("uuid") or row.get("id")
if job_id:
result[str(job_id)] = row
return result
def _wrap_in_model(self, settings: dict[str, Any]) -> dict[str, Any]:
"""Nest *settings* under the keys ``set`` expects, outermost last."""
payload: dict[str, Any] = settings
for key in reversed(self._ping_model_path()):
payload = {key: payload}
return payload
def _ping_model_path(self) -> tuple[str, ...]:
"""Keys from the model root down to the fields, e.g. ``("ping", "settings")``.
Read once from ``GET /api/diagnostics/ping/get`` rather than hardcoded,
so a model rename in a future release does not silently break job
creation. The response mirrors the model, so the path is the chain of
single-dict wrappers around the fields::
{"ping": {"settings": {"hostname": "", "fam": {...}, ...}}}
The descent stops at the first level that holds more than one key —
that is the field level, where ``fam`` is a dict too and following it
would land inside a form field.
Getting this wrong is expensive and quiet: OPNsense answers a job whose
hostname arrived at the wrong node with ``ping.settings.hostname: A
value is required``, and a sweep turns 254 such refusals into a network
that appears to hold nothing.
"""
if self._ping_model_path_cache is not None:
return self._ping_model_path_cache
path = _DEFAULT_PING_MODEL_PATH
try:
node = self._get(f"{_PING_API}/get")
found: list[str] = []
while isinstance(node, dict) and len(node) == 1:
key, value = next(iter(node.items()))
if not isinstance(value, dict):
break
found.append(key)
node = value
if found:
path = tuple(found)
except Exception as exc: # noqa: BLE001 - the default is what firmware ships
logger.debug("Could not read ping model path, assuming %r: %s", path, exc)
self._ping_model_path_cache = path
return path
# ── Parsing ──────────────────────────────────────────────────────────────
@staticmethod
def _address_family(destination: str) -> str:
"""``"ip"`` for IPv4 / hostnames, ``"ip6"`` for IPv6 literals."""
try:
return "ip6" if ip_address(destination).version == 6 else "ip"
except ValueError:
return "ip"
@staticmethod
def _row_to_napalm_reply(
destination: str, row: Optional[dict[str, Any]], *, count: int
) -> dict[str, Any]:
"""Turn one ``search_jobs`` row into NAPALM's ping reply shape.
A missing row, or one that never sent a probe, is reported as "all
probes lost" rather than as an error: from the caller's point of view
an unreachable host and a host the firewall never got around to
pinging look the same, and the sweep only asks "did it answer?".
"""
if row is None:
return _all_lost(count)
sent = _as_int(row.get("send"))
received = _as_int(row.get("received"))
if sent == 0:
return _all_lost(count)
avg = _as_float(row.get("avg"))
return {
"success": {
"probes_sent": sent,
"packet_loss": max(sent - received, 0),
"rtt_min": _as_float(row.get("min")),
"rtt_avg": avg,
"rtt_max": _as_float(row.get("max")),
"rtt_stddev": _as_float(row.get("std-dev")),
"results": [{"ip_address": destination, "rtt": avg}] if received else [],
}
}
def _all_lost(count: int) -> dict[str, Any]:
probes = max(count, 1)
return {
"success": {
"probes_sent": probes,
"packet_loss": probes,
"rtt_min": 0.0,
"rtt_avg": 0.0,
"rtt_max": 0.0,
"rtt_stddev": 0.0,
"results": [],
}
}
def _as_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _as_float(value: Any, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default