Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5323e2425 | ||
|
|
5a1aa66db4 | ||
|
|
5b54174cc4 | ||
|
|
2af99995e9 | ||
|
|
c868d41322 | ||
|
|
00172f7a69 | ||
|
|
e318c97c20 | ||
|
|
852c1d59df | ||
|
|
fa88850535 | ||
|
|
2fb1226734 | ||
|
|
8ce7ea52e7 | ||
|
|
a6c1d77791 | ||
|
|
b3e89f003c |
No files matched your search
@@ -71,9 +71,9 @@ arguments.
|
||||
|
||||
| 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_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_arp_table` | ✅ | `GET /api/diagnostics/interface/get_arp` |
|
||||
| `get_ipv6_neighbors_table` | ✅ | `GET /api/diagnostics/interface/get_ndp` |
|
||||
@@ -88,6 +88,8 @@ arguments.
|
||||
| `get_vlans` | ✅ | `GET /api/interfaces/vlan_settings/search_item` |
|
||||
| `get_mac_address_table` | ❌ | Not applicable (firewall, no L2 switching) |
|
||||
| `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 |
|
||||
|
||||
> ¹ Requires the `os-lldpd` plugin. Returns empty dict if the plugin is not installed.
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""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 typing import Any, Dict, Iterable, List, Optional, Set
|
||||
|
||||
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]) -> Optional[str]:
|
||||
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 == "*":
|
||||
return ("::" if protocol.endswith("6") else "0.0.0.0"), None
|
||||
host, _, zone = address.partition("%")
|
||||
return host, zone or None
|
||||
|
||||
|
||||
def _socket(
|
||||
entry: Dict[str, Any], services: Set[str], wireguard_ports: Set[int]
|
||||
) -> Optional[ListeningSocketDict]:
|
||||
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 "")
|
||||
)
|
||||
+135
-17
@@ -38,6 +38,7 @@ from __future__ import annotations
|
||||
import difflib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
from ipaddress import ip_address, ip_network
|
||||
@@ -53,10 +54,30 @@ import requests
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from napalm_device_types import FingerprintRule, FirewallDriver
|
||||
from napalm_device_types.models import ListeningSocketsDict, VirtualIpsDict
|
||||
from napalm.base.exceptions import ConnectionException, ConnectionClosedException, MergeConfigException
|
||||
|
||||
from napalm_opnsense.listening import parse_socket_statistics, wireguard_listen_ports
|
||||
from napalm_opnsense.ping_mixin import OPNsensePingMixin
|
||||
from napalm_opnsense.port_forwards import alias_index, port_forwards, wan_interfaces
|
||||
from napalm_opnsense.virtual_ips import carp_addresses
|
||||
|
||||
|
||||
_ARCHITECTURE_SUFFIX = re.compile(r"-(amd64|aarch64|arm64|i386|armv7)$")
|
||||
|
||||
|
||||
def _opnsense_version(versions: Any) -> str:
|
||||
"""``["OPNsense 26.7.7-amd64", "FreeBSD ..."]`` -> ``"26.7.7"``; "" when absent."""
|
||||
for line in versions if isinstance(versions, list) else []:
|
||||
product, _, version = str(line).partition(" ")
|
||||
if product == "OPNsense" and version:
|
||||
return _ARCHITECTURE_SUFFIX.sub("", version.strip())
|
||||
return ""
|
||||
|
||||
|
||||
def _carp_master(entry: dict[str, Any]) -> bool:
|
||||
"""Whether this node is MASTER for an interface address bound to a CARP VHID."""
|
||||
return str(entry.get("status") or "").strip().upper() == "MASTER"
|
||||
|
||||
|
||||
class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
|
||||
@@ -170,6 +191,22 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def _get_text(self, path: str) -> str:
|
||||
"""Perform a GET request and return the body as text.
|
||||
|
||||
For the few endpoints that answer with a file rather than JSON, such
|
||||
as the configuration backup.
|
||||
|
||||
:raises ConnectionClosedException: if called before :meth:`open`.
|
||||
:raises RequestException: on HTTP-level errors.
|
||||
"""
|
||||
if self.session is None:
|
||||
raise ConnectionClosedException("Not connected – call open() first.")
|
||||
url = self.base_url.rstrip("/") + path
|
||||
response = self.session.get(url, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
|
||||
def _post(self, path: str, data: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Perform a POST request against the OPNsense REST API.
|
||||
|
||||
@@ -192,16 +229,33 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
|
||||
def get_facts(self) -> dict[str, Any]:
|
||||
"""Return a dictionary of general device facts.
|
||||
|
||||
Calls ``GET /api/core/system/status``.
|
||||
Name and version come from ``GET /api/diagnostics/system/system_information``
|
||||
(``name`` is the FQDN, ``versions[0]`` reads ``"OPNsense 26.7.7-amd64"``):
|
||||
``/api/core/system/status`` carries neither on OPNsense 26.7. The status
|
||||
answer is still read, and still counts where the other one gives nothing --
|
||||
an older OPNsense, or an API key without the diagnostics privilege.
|
||||
|
||||
Returned keys (NAPALM standard):
|
||||
``vendor``, ``model``, ``hostname``, ``fqdn``, ``os_version``,
|
||||
``serial_number``, ``uptime``, ``interface_list``.
|
||||
"""
|
||||
status = self._get("/api/core/system/status")
|
||||
try:
|
||||
information = self._get("/api/diagnostics/system/system_information")
|
||||
except Exception as exc:
|
||||
logger.debug("system_information not read: %s", exc)
|
||||
information = {}
|
||||
if not isinstance(information, dict):
|
||||
information = {}
|
||||
|
||||
hostname = status.get("hostname") or status.get("name") or ""
|
||||
version = status.get("version") or status.get("product_version") or "unknown"
|
||||
fqdn = str(information.get("name") or status.get("hostname") or status.get("name") or "")
|
||||
hostname = fqdn.split(".", 1)[0]
|
||||
version = (
|
||||
_opnsense_version(information.get("versions"))
|
||||
or status.get("version")
|
||||
or status.get("product_version")
|
||||
or "unknown"
|
||||
)
|
||||
|
||||
try:
|
||||
interface_list = list(self.get_interfaces().keys())
|
||||
@@ -213,7 +267,7 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
|
||||
"vendor": self.VENDOR,
|
||||
"model": status.get("model") or self.VENDOR,
|
||||
"hostname": hostname,
|
||||
"fqdn": hostname,
|
||||
"fqdn": fqdn,
|
||||
"os_version": version,
|
||||
"serial_number": status.get("serial") or "",
|
||||
"uptime": status.get("uptime", -1),
|
||||
@@ -263,6 +317,10 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
|
||||
|
||||
Uses ``GET /api/interfaces/overview/export`` (same source as get_interfaces).
|
||||
|
||||
An address bound to a CARP VHID is listed on every node; it counts as this
|
||||
node's only while the node is its MASTER, as keepalived puts the address on
|
||||
the master alone (netOrk #1121).
|
||||
|
||||
Structure::
|
||||
|
||||
{
|
||||
@@ -298,8 +356,14 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
|
||||
_add(name, addr4, "ipv4")
|
||||
if addr6:
|
||||
_add(name, addr6, "ipv6")
|
||||
for family in ("ipv4", "ipv6"):
|
||||
for entry in iface.get(family) or []:
|
||||
if isinstance(entry, dict) and entry.get("vhid") and _carp_master(entry):
|
||||
_add(name, entry.get("ipaddr") or "", family)
|
||||
if not addr4:
|
||||
for entry in iface.get("ipv4") or []:
|
||||
if entry.get("vhid") and not _carp_master(entry):
|
||||
continue
|
||||
ip_field = entry.get("ipaddr") or entry.get("ip", "")
|
||||
subnet = entry.get("subnetbits") or entry.get("prefix_length")
|
||||
cidr = f"{ip_field}/{subnet}" if subnet and "/" not in ip_field else ip_field
|
||||
@@ -307,6 +371,8 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
|
||||
_add(name, cidr, "ipv4")
|
||||
if not addr6:
|
||||
for entry in iface.get("ipv6") or []:
|
||||
if entry.get("vhid") and not _carp_master(entry):
|
||||
continue
|
||||
ip_field = entry.get("ipaddr") or entry.get("ip", "")
|
||||
prefix = entry.get("prefixlen") or entry.get("prefix_length")
|
||||
cidr = f"{ip_field}/{prefix}" if prefix and "/" not in ip_field else ip_field
|
||||
@@ -833,22 +899,19 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
|
||||
the JSON-serialised staged routes when a candidate has been loaded,
|
||||
or an empty string otherwise.
|
||||
|
||||
Calls ``GET /api/core/backup/download/this``.
|
||||
Calls ``GET /api/core/backup/download/this``, which answers with
|
||||
``config.xml`` itself rather than JSON. A failed read raises: an empty
|
||||
``running`` would claim the device has no configuration and hide why
|
||||
the read failed, e.g. an API key without the backup privilege.
|
||||
"""
|
||||
configs: dict[str, str] = {"running": "", "startup": "", "candidate": ""}
|
||||
|
||||
if retrieve in ("all", "running", "startup"):
|
||||
try:
|
||||
response = self._get("/api/core/backup/download/this")
|
||||
xml_text: str = (
|
||||
response if isinstance(response, str) else str(response)
|
||||
)
|
||||
if retrieve in ("all", "running"):
|
||||
configs["running"] = xml_text
|
||||
if retrieve in ("all", "startup"):
|
||||
configs["startup"] = xml_text
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch running/startup config: %s", exc)
|
||||
xml_text = self._get_text("/api/core/backup/download/this")
|
||||
if retrieve in ("all", "running"):
|
||||
configs["running"] = xml_text
|
||||
if retrieve in ("all", "startup"):
|
||||
configs["startup"] = xml_text
|
||||
|
||||
if retrieve in ("all", "candidate") and self._candidate_config is not None:
|
||||
configs["candidate"] = json.dumps(self._candidate_config, indent=2)
|
||||
@@ -1789,6 +1852,42 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
|
||||
})
|
||||
return sorted(result, key=lambda x: x["name"].lower())
|
||||
|
||||
def get_listening_sockets(self) -> ListeningSocketsDict:
|
||||
"""Every listening TCP and bound UDP socket, with the OPNsense service behind it.
|
||||
|
||||
Read from ``/api/diagnostics/interface/get_socket_statistics``, which
|
||||
configd collects as root, so every socket names its process where it has
|
||||
one (``attributed``). Which service: :mod:`napalm_opnsense.listening`.
|
||||
"""
|
||||
statistics = self._get("/api/diagnostics/interface/get_socket_statistics")
|
||||
services = [service["name"] for service in self.get_services()]
|
||||
try:
|
||||
wireguard = wireguard_listen_ports(self._get("/api/wireguard/service/show"))
|
||||
except Exception as exc:
|
||||
# No WireGuard here: its kernel sockets stay nobody's.
|
||||
logger.debug("WireGuard listen ports not read: %s", exc)
|
||||
wireguard = set()
|
||||
return {
|
||||
"attributed": True,
|
||||
"sockets": parse_socket_statistics(statistics, services, wireguard),
|
||||
}
|
||||
|
||||
def get_virtual_ips(self) -> VirtualIpsDict:
|
||||
"""The firewall's CARP addresses, and whether this node holds each.
|
||||
|
||||
From ``/api/interfaces/overview/export``: every address bound to a CARP
|
||||
VHID, the CARP address and IP aliases on its VHID alike, with this node's
|
||||
CARP state (:mod:`napalm_opnsense.virtual_ips`). vip-manager and keepalived
|
||||
are Linux mechanisms and are not looked for (None).
|
||||
|
||||
:raises: whatever the request raises -- an unread overview is not "no CARP".
|
||||
"""
|
||||
return {
|
||||
"vip_manager": None,
|
||||
"keepalived": None,
|
||||
"carp": carp_addresses(self._get("/api/interfaces/overview/export")),
|
||||
}
|
||||
|
||||
def manage_service(self, name: str, action: str) -> dict[str, Any]:
|
||||
"""Execute a lifecycle action on an OPNsense service.
|
||||
|
||||
@@ -2050,6 +2149,16 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
|
||||
"output": f"The update check did not finish within {_REFRESH_POLLS * _REFRESH_INTERVAL} s",
|
||||
}
|
||||
|
||||
def reboot_host(self) -> None:
|
||||
"""Restart the firewall through its firmware API (``HostRebootMixin``).
|
||||
|
||||
:raises RuntimeError: when the API refuses the request.
|
||||
"""
|
||||
try:
|
||||
self._post("/api/core/firmware/reboot")
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"The firewall refused to reboot: {exc}") from exc
|
||||
|
||||
def get_host_status(self) -> dict[str, Any]:
|
||||
"""Whether the firewall needs a reboot to finish an update; it does not
|
||||
patch itself as far as netOrk can tell."""
|
||||
@@ -2871,8 +2980,17 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
|
||||
# Re-add all enabled A/AAAA records, one override per logical record.
|
||||
# Two rows for the same name is a state netOrk's own database can
|
||||
# legitimately be in; the device must still end up with one.
|
||||
# An enabled override made by hand already answers for its record: the
|
||||
# caller's records include what it read off this device, and adding
|
||||
# those again under the tag left each one twice (NetOrk/netork#1123).
|
||||
added = 0
|
||||
pushed: set[tuple[str, str, str]] = set()
|
||||
pushed: set[tuple[str, str, str]] = {
|
||||
(entry["hostname"].lower(), entry.get("record_type", "A"), entry["ip"])
|
||||
for entry in existing
|
||||
if entry["domain"].rstrip(".") == zone_clean
|
||||
and not entry["description"].startswith(self._NETORK_TAG)
|
||||
and entry.get("enabled", True)
|
||||
}
|
||||
for rec in records:
|
||||
if not rec.get("enabled", True):
|
||||
continue
|
||||
|
||||
@@ -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, Optional
|
||||
|
||||
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) -> Optional[tuple[str, Optional[int]]]:
|
||||
"""``"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: Optional[int],
|
||||
vhid: object,
|
||||
state: Optional[str],
|
||||
instance: str,
|
||||
interface: Optional[str],
|
||||
) -> Optional[VirtualIpDict]:
|
||||
"""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)
|
||||
+1
-1
@@ -24,7 +24,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"napalm>=4.0.0",
|
||||
"napalm_device_types>=0.2.0",
|
||||
"napalm_device_types>=4.10.1",
|
||||
"requests>=2.28.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"
|
||||
+118
-3
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import pytest
|
||||
import requests
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from napalm_opnsense.opnsense import OPNsenseDriver
|
||||
@@ -1656,24 +1657,74 @@ class TestRollback:
|
||||
|
||||
class TestGetConfigCandidate:
|
||||
def test_candidate_empty_without_staged_config(self, driver):
|
||||
driver._get = lambda path: "<opnsense/>"
|
||||
driver._get_text = lambda path: "<opnsense/>"
|
||||
result = driver.get_config()
|
||||
assert result["candidate"] == ""
|
||||
|
||||
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)
|
||||
result = driver.get_config()
|
||||
assert "WAN_GW" in result["candidate"]
|
||||
|
||||
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)
|
||||
result = driver.get_config()
|
||||
parsed = json.loads(result["candidate"])
|
||||
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()
|
||||
#
|
||||
@@ -2545,6 +2596,70 @@ class TestSyncDnsZone:
|
||||
added = [d for p, d in calls if p.endswith("addhostoverride")]
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
@@ -123,3 +123,18 @@ class TestHostStatus:
|
||||
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()
|
||||
@@ -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