Compare commits
3
Commits
b5323e2425
...
e3ed0f1bdd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3ed0f1bdd | ||
|
|
055ec3da71 | ||
|
|
e334dc0058 |
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` |
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Kea's HA peers and the firewall's configuration sync, as facts (netOrk #1125).
|
||||
|
||||
Two answers of the OPNsense API, read as they are:
|
||||
|
||||
* ``/api/kea/dhcpv4/get`` (its ``ha`` section) and ``/api/kea/dhcpv4/searchPeer``:
|
||||
whether Kea's HA hook is on, which peer this firewall is, and each peer's name,
|
||||
role and HA listener URL. Configuration, not state -- Kea's control agent, which
|
||||
knows whether the pair is in hot standby right now, listens on localhost only.
|
||||
* ``/api/core/hasync/get``: the address this firewall pushes its configuration
|
||||
to (``synchronizetoip``) and the ticked sync items. An item is named by
|
||||
OPNsense's sync section, not by the service it covers; :data:`SYNC_ITEM_SERVICES`
|
||||
maps the ones that are a service to the name ``get_services`` reports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from napalm_device_types.models import ConfigSyncDict, DhcpHaDict, DhcpHaPeerDict
|
||||
|
||||
#: Kea's DHCPv4 server as ``get_services`` names it.
|
||||
KEA_SERVICE = "kea-dhcp"
|
||||
|
||||
#: Sync item -> the service it carries the configuration of, by the name
|
||||
#: ``get_services`` reports. Items that are configuration only (aliases, rules,
|
||||
#: NAT, users ...) have no service and are reported as unmapped.
|
||||
SYNC_ITEM_SERVICES: dict[str, str] = {
|
||||
"captiveportal": "captiveportal",
|
||||
"cron": "cron",
|
||||
"ddclient": "ddclient",
|
||||
"dnsforwarder": "dnsmasq",
|
||||
"dnsresolver": "unbound",
|
||||
"hostwatch": "hostwatch",
|
||||
"kea": KEA_SERVICE,
|
||||
"mdns-repeater": "mdns-repeater",
|
||||
"netsnmp": "snmpd",
|
||||
"ntpd": "ntpd",
|
||||
"qemuguestagent": "qemu-ga",
|
||||
"quagga": "frr",
|
||||
"radvd": "radvd",
|
||||
"squid": "squid",
|
||||
"ssh": "openssh",
|
||||
"sysctl": "sysctl",
|
||||
"syslog-ng": "syslog-ng",
|
||||
"webgui": "webgui",
|
||||
"wireguard": "wireguard",
|
||||
"zabbixagent": "zabbix_agentd",
|
||||
}
|
||||
|
||||
|
||||
def _section(answer: Any, *path: str) -> dict:
|
||||
node = answer
|
||||
for key in path:
|
||||
if not isinstance(node, dict) or not isinstance(node.get(key), dict):
|
||||
raise ValueError(f"unexpected answer: no {'.'.join(path)}")
|
||||
node = node[key]
|
||||
return node
|
||||
|
||||
|
||||
def dhcp_ha(settings: Any, peers: Any) -> DhcpHaDict:
|
||||
"""Kea's HA configuration from the two answers.
|
||||
|
||||
``this_server_name`` is empty while left at its default, the hostname; the
|
||||
effective name is then in its ``%`` twin.
|
||||
|
||||
:raises ValueError: when *settings* has no ``dhcpv4.ha`` section.
|
||||
"""
|
||||
ha = _section(settings, "dhcpv4", "ha")
|
||||
result: DhcpHaDict = {
|
||||
"service": KEA_SERVICE,
|
||||
"enabled": str(ha.get("enabled", "0")) == "1",
|
||||
"peers": [],
|
||||
}
|
||||
this_server = str(ha.get("this_server_name") or ha.get("%this_server_name") or "").strip()
|
||||
if this_server:
|
||||
result["this_server"] = this_server
|
||||
rows = peers.get("rows") if isinstance(peers, dict) else None
|
||||
for row in rows or []:
|
||||
if not isinstance(row, dict) or not row.get("name"):
|
||||
continue
|
||||
peer: DhcpHaPeerDict = {"name": str(row["name"]), "role": str(row.get("role") or "")}
|
||||
if row.get("url"):
|
||||
peer["url"] = str(row["url"])
|
||||
result["peers"].append(peer)
|
||||
return result
|
||||
|
||||
|
||||
def config_sync(answer: Any) -> ConfigSyncDict:
|
||||
"""Where this firewall pushes its configuration, and which services' it pushes.
|
||||
|
||||
Ticked items without a target address push nothing, so they count for nothing.
|
||||
|
||||
:raises ValueError: when *answer* has no ``hasync`` section.
|
||||
"""
|
||||
section = _section(answer, "hasync")
|
||||
peer = str(section.get("synchronizetoip") or "").strip() or None
|
||||
if peer is None:
|
||||
return {"peer": None, "services": [], "unmapped": []}
|
||||
items = section.get("syncitems")
|
||||
ticked = sorted(
|
||||
name
|
||||
for name, item in (items.items() if isinstance(items, dict) else [])
|
||||
if isinstance(item, dict) and str(item.get("selected", "0")) == "1"
|
||||
)
|
||||
return {
|
||||
"peer": peer,
|
||||
"services": sorted({SYNC_ITEM_SERVICES[i] for i in ticked if i in SYNC_ITEM_SERVICES}),
|
||||
"unmapped": [i for i in ticked if i not in SYNC_ITEM_SERVICES],
|
||||
}
|
||||
@@ -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
|
||||
@@ -52,17 +53,40 @@ _REFRESH_INTERVAL = 3
|
||||
import requests
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from napalm_device_types import FingerprintRule, FirewallDriver
|
||||
from napalm_device_types.models import ListeningSocketsDict, VirtualIpsDict
|
||||
from napalm_device_types import ConfigSyncMixin, FingerprintRule, FirewallDriver
|
||||
from napalm_device_types.models import (
|
||||
ConfigSyncDict,
|
||||
DhcpHaDict,
|
||||
ListeningSocketsDict,
|
||||
VirtualIpsDict,
|
||||
)
|
||||
from napalm.base.exceptions import ConnectionException, ConnectionClosedException, MergeConfigException
|
||||
|
||||
from napalm_opnsense.ha import config_sync, dhcp_ha
|
||||
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
|
||||
|
||||
|
||||
class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
|
||||
_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, ConfigSyncMixin, FirewallDriver):
|
||||
"""NAPALM driver for OPNsense (read-only, REST API)."""
|
||||
|
||||
VENDOR = "OPNsense"
|
||||
@@ -211,16 +235,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())
|
||||
@@ -232,7 +273,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),
|
||||
@@ -282,6 +323,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::
|
||||
|
||||
{
|
||||
@@ -317,8 +362,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
|
||||
@@ -326,6 +377,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
|
||||
@@ -1841,6 +1894,24 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
|
||||
"carp": carp_addresses(self._get("/api/interfaces/overview/export")),
|
||||
}
|
||||
|
||||
def get_dhcp_ha(self) -> DhcpHaDict:
|
||||
"""Kea's HA configuration: on or off, this firewall's peer name, every peer.
|
||||
|
||||
Configuration, not state (:mod:`napalm_opnsense.ha`).
|
||||
|
||||
:raises: whatever either request raises -- an unread answer is not "off".
|
||||
"""
|
||||
return dhcp_ha(
|
||||
self._get("/api/kea/dhcpv4/get"), self._get("/api/kea/dhcpv4/searchPeer")
|
||||
)
|
||||
|
||||
def get_config_sync(self) -> ConfigSyncDict:
|
||||
"""Where this firewall pushes its configuration (HA sync), and for which services.
|
||||
|
||||
:raises: whatever the request raises -- an unread answer is not "no sync".
|
||||
"""
|
||||
return config_sync(self._get("/api/core/hasync/get"))
|
||||
|
||||
def manage_service(self, name: str, action: str) -> dict[str, Any]:
|
||||
"""Execute a lifecycle action on an OPNsense service.
|
||||
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"napalm>=4.0.0",
|
||||
"napalm_device_types>=4.10.1",
|
||||
"napalm_device_types>=4.11.0",
|
||||
"requests>=2.28.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
{
|
||||
"gw-01": {
|
||||
"kea_dhcpv4_get": {
|
||||
"dhcpv4": {
|
||||
"ha": {
|
||||
"enabled": "1",
|
||||
"this_server_name": "",
|
||||
"max_unacked_clients": "2",
|
||||
"%this_server_name": "gw"
|
||||
}
|
||||
}
|
||||
},
|
||||
"kea_dhcpv4_searchPeer": {
|
||||
"rows": [
|
||||
{
|
||||
"uuid": "574e7b41-a5c7-4682-bc63-f4a99e07f262",
|
||||
"name": "gw",
|
||||
"role": "primary",
|
||||
"%role": "Primary",
|
||||
"url": "http://10.5.100.2:8001/"
|
||||
},
|
||||
{
|
||||
"uuid": "d6a4e812-4bc8-4713-bebb-b330ea65f587",
|
||||
"name": "gw-02",
|
||||
"role": "standby",
|
||||
"%role": "Standby",
|
||||
"url": "http://10.5.100.3:8001/"
|
||||
}
|
||||
],
|
||||
"rowCount": 2,
|
||||
"total": 2,
|
||||
"current": 1
|
||||
},
|
||||
"core_hasync_get": {
|
||||
"hasync": {
|
||||
"synchronizetoip": "10.5.100.3",
|
||||
"syncitems": {
|
||||
"aliases": {
|
||||
"value": "Aliases",
|
||||
"selected": 1
|
||||
},
|
||||
"authservers": {
|
||||
"value": "Authentication Servers",
|
||||
"selected": 1
|
||||
},
|
||||
"captiveportal": {
|
||||
"value": "Captive Portal",
|
||||
"selected": 1
|
||||
},
|
||||
"certs": {
|
||||
"value": "Certificates and Authorities",
|
||||
"selected": 1
|
||||
},
|
||||
"cron": {
|
||||
"value": "Cron",
|
||||
"selected": 0
|
||||
},
|
||||
"ddclient": {
|
||||
"value": "ddclient",
|
||||
"selected": 1
|
||||
},
|
||||
"dhcrelay": {
|
||||
"value": "DHCRelay",
|
||||
"selected": 0
|
||||
},
|
||||
"dnsforwarder": {
|
||||
"value": "Dnsmasq DNS/DHCP",
|
||||
"selected": 0
|
||||
},
|
||||
"categories": {
|
||||
"value": "Firewall Categories",
|
||||
"selected": 1
|
||||
},
|
||||
"ifgroups": {
|
||||
"value": "Firewall Groups",
|
||||
"selected": 1
|
||||
},
|
||||
"lvtemplate": {
|
||||
"value": "Firewall Log Templates",
|
||||
"selected": 1
|
||||
},
|
||||
"rules": {
|
||||
"value": "Firewall Rules",
|
||||
"selected": 1
|
||||
},
|
||||
"schedules": {
|
||||
"value": "Firewall Schedules",
|
||||
"selected": 1
|
||||
},
|
||||
"freeradius": {
|
||||
"value": "Freeradius",
|
||||
"selected": 0
|
||||
},
|
||||
"quagga": {
|
||||
"value": "FRR",
|
||||
"selected": 0
|
||||
},
|
||||
"hostwatch": {
|
||||
"value": "Host discovery service",
|
||||
"selected": 0
|
||||
},
|
||||
"suricata": {
|
||||
"value": "Intrusion Detection",
|
||||
"selected": 0
|
||||
},
|
||||
"ipsec": {
|
||||
"value": "IPsec",
|
||||
"selected": 0
|
||||
},
|
||||
"kea": {
|
||||
"value": "Kea DHCP",
|
||||
"selected": 1
|
||||
},
|
||||
"mdns-repeater": {
|
||||
"value": "mDNS Repeater",
|
||||
"selected": 0
|
||||
},
|
||||
"monit": {
|
||||
"value": "Monit System Monitoring",
|
||||
"selected": 0
|
||||
},
|
||||
"nat": {
|
||||
"value": "NAT",
|
||||
"selected": 1
|
||||
},
|
||||
"syslog": {
|
||||
"value": "Netflow / Insight",
|
||||
"selected": 0
|
||||
},
|
||||
"netsnmp": {
|
||||
"value": "Netsnmp",
|
||||
"selected": 0
|
||||
},
|
||||
"ntpd": {
|
||||
"value": "Network Time",
|
||||
"selected": 1
|
||||
},
|
||||
"opendns": {
|
||||
"value": "OpenDNS",
|
||||
"selected": 0
|
||||
},
|
||||
"ssh": {
|
||||
"value": "OpenSSH",
|
||||
"selected": 1
|
||||
},
|
||||
"openvpn": {
|
||||
"value": "OpenVPN",
|
||||
"selected": 0
|
||||
},
|
||||
"qemuguestagent": {
|
||||
"value": "QEMU Guest Agent",
|
||||
"selected": 1
|
||||
},
|
||||
"radvd": {
|
||||
"value": "Router Advertisements",
|
||||
"selected": 0
|
||||
},
|
||||
"shaper": {
|
||||
"value": "Shaper",
|
||||
"selected": 0
|
||||
},
|
||||
"squid": {
|
||||
"value": "Squid Web Proxy",
|
||||
"selected": 0
|
||||
},
|
||||
"staticroutes": {
|
||||
"value": "Static Routes",
|
||||
"selected": 1
|
||||
},
|
||||
"syslog-ng": {
|
||||
"value": "System logging",
|
||||
"selected": 1
|
||||
},
|
||||
"sysctl": {
|
||||
"value": "System Tunables",
|
||||
"selected": 1
|
||||
},
|
||||
"dnsresolver": {
|
||||
"value": "Unbound DNS",
|
||||
"selected": 1
|
||||
},
|
||||
"users": {
|
||||
"value": "Users and Groups",
|
||||
"selected": 1
|
||||
},
|
||||
"virtualip": {
|
||||
"value": "Virtual IPs",
|
||||
"selected": 1
|
||||
},
|
||||
"wol": {
|
||||
"value": "Wake on LAN",
|
||||
"selected": 0
|
||||
},
|
||||
"webgui": {
|
||||
"value": "Web GUI",
|
||||
"selected": 1
|
||||
},
|
||||
"wireguard": {
|
||||
"value": "WireGuard",
|
||||
"selected": 1
|
||||
},
|
||||
"zabbixagent": {
|
||||
"value": "Zabbix monitoring agent",
|
||||
"selected": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"gw-02": {
|
||||
"kea_dhcpv4_get": {
|
||||
"dhcpv4": {
|
||||
"ha": {
|
||||
"enabled": "1",
|
||||
"this_server_name": "",
|
||||
"max_unacked_clients": "2",
|
||||
"%this_server_name": "gw-02"
|
||||
}
|
||||
}
|
||||
},
|
||||
"kea_dhcpv4_searchPeer": {
|
||||
"rows": [
|
||||
{
|
||||
"uuid": "574e7b41-a5c7-4682-bc63-f4a99e07f262",
|
||||
"name": "gw",
|
||||
"role": "primary",
|
||||
"%role": "Primary",
|
||||
"url": "http://10.5.100.2:8001/"
|
||||
},
|
||||
{
|
||||
"uuid": "d6a4e812-4bc8-4713-bebb-b330ea65f587",
|
||||
"name": "gw-02",
|
||||
"role": "standby",
|
||||
"%role": "Standby",
|
||||
"url": "http://10.5.100.3:8001/"
|
||||
}
|
||||
],
|
||||
"rowCount": 2,
|
||||
"total": 2,
|
||||
"current": 1
|
||||
},
|
||||
"core_hasync_get": {
|
||||
"hasync": {
|
||||
"synchronizetoip": "",
|
||||
"syncitems": {
|
||||
"aliases": {
|
||||
"value": "Aliases",
|
||||
"selected": 0
|
||||
},
|
||||
"authservers": {
|
||||
"value": "Authentication Servers",
|
||||
"selected": 0
|
||||
},
|
||||
"captiveportal": {
|
||||
"value": "Captive Portal",
|
||||
"selected": 0
|
||||
},
|
||||
"certs": {
|
||||
"value": "Certificates and Authorities",
|
||||
"selected": 0
|
||||
},
|
||||
"cron": {
|
||||
"value": "Cron",
|
||||
"selected": 0
|
||||
},
|
||||
"ddclient": {
|
||||
"value": "ddclient",
|
||||
"selected": 0
|
||||
},
|
||||
"dhcrelay": {
|
||||
"value": "DHCRelay",
|
||||
"selected": 0
|
||||
},
|
||||
"dnsforwarder": {
|
||||
"value": "Dnsmasq DNS/DHCP",
|
||||
"selected": 0
|
||||
},
|
||||
"categories": {
|
||||
"value": "Firewall Categories",
|
||||
"selected": 0
|
||||
},
|
||||
"ifgroups": {
|
||||
"value": "Firewall Groups",
|
||||
"selected": 0
|
||||
},
|
||||
"lvtemplate": {
|
||||
"value": "Firewall Log Templates",
|
||||
"selected": 0
|
||||
},
|
||||
"rules": {
|
||||
"value": "Firewall Rules",
|
||||
"selected": 0
|
||||
},
|
||||
"schedules": {
|
||||
"value": "Firewall Schedules",
|
||||
"selected": 0
|
||||
},
|
||||
"freeradius": {
|
||||
"value": "Freeradius",
|
||||
"selected": 0
|
||||
},
|
||||
"quagga": {
|
||||
"value": "FRR",
|
||||
"selected": 0
|
||||
},
|
||||
"hostwatch": {
|
||||
"value": "Host discovery service",
|
||||
"selected": 0
|
||||
},
|
||||
"suricata": {
|
||||
"value": "Intrusion Detection",
|
||||
"selected": 0
|
||||
},
|
||||
"ipsec": {
|
||||
"value": "IPsec",
|
||||
"selected": 0
|
||||
},
|
||||
"kea": {
|
||||
"value": "Kea DHCP",
|
||||
"selected": 0
|
||||
},
|
||||
"mdns-repeater": {
|
||||
"value": "mDNS Repeater",
|
||||
"selected": 0
|
||||
},
|
||||
"monit": {
|
||||
"value": "Monit System Monitoring",
|
||||
"selected": 0
|
||||
},
|
||||
"nat": {
|
||||
"value": "NAT",
|
||||
"selected": 0
|
||||
},
|
||||
"syslog": {
|
||||
"value": "Netflow / Insight",
|
||||
"selected": 0
|
||||
},
|
||||
"netsnmp": {
|
||||
"value": "Netsnmp",
|
||||
"selected": 0
|
||||
},
|
||||
"ntpd": {
|
||||
"value": "Network Time",
|
||||
"selected": 0
|
||||
},
|
||||
"opendns": {
|
||||
"value": "OpenDNS",
|
||||
"selected": 0
|
||||
},
|
||||
"ssh": {
|
||||
"value": "OpenSSH",
|
||||
"selected": 0
|
||||
},
|
||||
"openvpn": {
|
||||
"value": "OpenVPN",
|
||||
"selected": 0
|
||||
},
|
||||
"qemuguestagent": {
|
||||
"value": "QEMU Guest Agent",
|
||||
"selected": 0
|
||||
},
|
||||
"radvd": {
|
||||
"value": "Router Advertisements",
|
||||
"selected": 0
|
||||
},
|
||||
"shaper": {
|
||||
"value": "Shaper",
|
||||
"selected": 0
|
||||
},
|
||||
"squid": {
|
||||
"value": "Squid Web Proxy",
|
||||
"selected": 0
|
||||
},
|
||||
"staticroutes": {
|
||||
"value": "Static Routes",
|
||||
"selected": 0
|
||||
},
|
||||
"syslog-ng": {
|
||||
"value": "System logging",
|
||||
"selected": 0
|
||||
},
|
||||
"sysctl": {
|
||||
"value": "System Tunables",
|
||||
"selected": 0
|
||||
},
|
||||
"dnsresolver": {
|
||||
"value": "Unbound DNS",
|
||||
"selected": 0
|
||||
},
|
||||
"users": {
|
||||
"value": "Users and Groups",
|
||||
"selected": 0
|
||||
},
|
||||
"virtualip": {
|
||||
"value": "Virtual IPs",
|
||||
"selected": 0
|
||||
},
|
||||
"wol": {
|
||||
"value": "Wake on LAN",
|
||||
"selected": 0
|
||||
},
|
||||
"webgui": {
|
||||
"value": "Web GUI",
|
||||
"selected": 0
|
||||
},
|
||||
"wireguard": {
|
||||
"value": "WireGuard",
|
||||
"selected": 0
|
||||
},
|
||||
"zabbixagent": {
|
||||
"value": "Zabbix monitoring agent",
|
||||
"selected": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Kea's HA peers and the firewall's configuration sync, as facts (netOrk #1125).
|
||||
|
||||
The fixture is what gw-01 and gw-02 answered on 2026-10-16: Kea in hot standby
|
||||
(gw primary, gw-02 standby) and gw-01 pushing its configuration, Kea and
|
||||
Unbound included, to gw-02.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_opnsense.ha import config_sync, dhcp_ha
|
||||
from napalm_opnsense.opnsense import OPNsenseDriver
|
||||
|
||||
PAIR = json.loads((Path(__file__).parent / "fixtures" / "ha_pair.json").read_text())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def driver() -> OPNsenseDriver:
|
||||
d = OPNsenseDriver("gw-01", "user", "secret")
|
||||
d._session = MagicMock()
|
||||
return d
|
||||
|
||||
|
||||
def _answers(node: str):
|
||||
def _get(path: str):
|
||||
return {
|
||||
"/api/kea/dhcpv4/get": PAIR[node]["kea_dhcpv4_get"],
|
||||
"/api/kea/dhcpv4/searchPeer": PAIR[node]["kea_dhcpv4_searchPeer"],
|
||||
"/api/core/hasync/get": PAIR[node]["core_hasync_get"],
|
||||
}[path]
|
||||
|
||||
return _get
|
||||
|
||||
|
||||
class TestDhcpHa:
|
||||
def test_reads_the_peers_with_their_roles(self, driver):
|
||||
driver._get = _answers("gw-01")
|
||||
|
||||
ha = driver.get_dhcp_ha()
|
||||
|
||||
assert ha["service"] == "kea-dhcp"
|
||||
assert ha["enabled"] is True
|
||||
assert ha["peers"] == [
|
||||
{"name": "gw", "role": "primary", "url": "http://10.5.100.2:8001/"},
|
||||
{"name": "gw-02", "role": "standby", "url": "http://10.5.100.3:8001/"},
|
||||
]
|
||||
|
||||
def test_names_this_server_from_the_effective_value(self, driver):
|
||||
"""`this_server_name` is empty when left at its default -- the
|
||||
hostname; the effective name is in its `%` twin."""
|
||||
driver._get = _answers("gw-02")
|
||||
|
||||
assert driver.get_dhcp_ha()["this_server"] == "gw-02"
|
||||
|
||||
def test_an_explicit_server_name_wins(self):
|
||||
ha = dhcp_ha(
|
||||
{"dhcpv4": {"ha": {"enabled": "1", "this_server_name": "left", "%this_server_name": "x"}}},
|
||||
{"rows": []},
|
||||
)
|
||||
assert ha["this_server"] == "left"
|
||||
|
||||
def test_ha_switched_off(self):
|
||||
ha = dhcp_ha({"dhcpv4": {"ha": {"enabled": "0", "this_server_name": ""}}}, {"rows": []})
|
||||
|
||||
assert ha["enabled"] is False
|
||||
assert ha["peers"] == []
|
||||
assert "this_server" not in ha
|
||||
|
||||
def test_a_peer_without_url_has_no_url_key(self):
|
||||
ha = dhcp_ha(
|
||||
{"dhcpv4": {"ha": {"enabled": "1"}}},
|
||||
{"rows": [{"name": "a", "role": "primary", "url": ""}]},
|
||||
)
|
||||
assert ha["peers"] == [{"name": "a", "role": "primary"}]
|
||||
|
||||
def test_a_failed_read_raises_and_never_says_off(self, driver):
|
||||
driver._get = MagicMock(side_effect=RuntimeError("timeout"))
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
driver.get_dhcp_ha()
|
||||
|
||||
def test_an_answer_without_the_ha_section_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
dhcp_ha({"dhcpv4": {}}, {"rows": []})
|
||||
|
||||
|
||||
class TestConfigSync:
|
||||
def test_the_pushing_side_names_its_peer_and_services(self, driver):
|
||||
driver._get = _answers("gw-01")
|
||||
|
||||
sync = driver.get_config_sync()
|
||||
|
||||
assert sync["peer"] == "10.5.100.3"
|
||||
assert {"kea-dhcp", "unbound", "ntpd", "openssh", "wireguard"} <= set(sync["services"])
|
||||
assert sync["services"] == sorted(sync["services"])
|
||||
|
||||
def test_items_without_a_service_stay_visible(self, driver):
|
||||
driver._get = _answers("gw-01")
|
||||
|
||||
unmapped = driver.get_config_sync()["unmapped"]
|
||||
|
||||
assert {"aliases", "rules", "virtualip"} <= set(unmapped)
|
||||
assert "kea" not in unmapped
|
||||
|
||||
def test_only_selected_items_count(self, driver):
|
||||
driver._get = _answers("gw-01")
|
||||
|
||||
sync = driver.get_config_sync()
|
||||
|
||||
assert "dnsmasq" not in sync["services"] # dnsforwarder: not selected
|
||||
assert "cron" not in sync["unmapped"] and "cron" not in sync["services"]
|
||||
|
||||
def test_the_receiving_side_pushes_to_no_one(self, driver):
|
||||
driver._get = _answers("gw-02")
|
||||
|
||||
sync = driver.get_config_sync()
|
||||
|
||||
assert sync == {"peer": None, "services": [], "unmapped": []}
|
||||
|
||||
def test_without_a_peer_selected_items_are_not_pushed(self):
|
||||
"""Ticked items without a target address push nothing."""
|
||||
answer = {"hasync": {"synchronizetoip": "", "syncitems": {"kea": {"value": "Kea", "selected": 1}}}}
|
||||
|
||||
assert config_sync(answer) == {"peer": None, "services": [], "unmapped": []}
|
||||
|
||||
def test_a_failed_read_raises(self, driver):
|
||||
driver._get = MagicMock(side_effect=RuntimeError("timeout"))
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
driver.get_config_sync()
|
||||
|
||||
def test_an_answer_without_the_section_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
config_sync({"something": {}})
|
||||
@@ -0,0 +1,132 @@
|
||||
"""What the firewall is called, which version it runs, and which addresses it serves.
|
||||
|
||||
``/api/core/system/status`` on OPNsense 26.7 carries neither name nor version
|
||||
(``metadata`` and ``subsystems`` only), so every firewall read as nameless and
|
||||
"unknown". ``/api/diagnostics/system/system_information`` has both: ``name`` is
|
||||
the FQDN, ``versions[0]`` reads ``"OPNsense 26.7.7-amd64"``.
|
||||
|
||||
An address bound to a CARP VHID sits on the interface of every node, in state
|
||||
MASTER or BACKUP. Only the MASTER answers for it, so only there is it one of the
|
||||
node's addresses -- the same as keepalived, where only the master has the address
|
||||
at all (netOrk #1121: a virtual IP's badge sits on its holder's interface).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_opnsense.opnsense import OPNsenseDriver
|
||||
|
||||
OVERVIEW = json.loads(
|
||||
(Path(__file__).parent / "fixtures" / "interfaces_overview_carp.json").read_text()
|
||||
)
|
||||
SYSTEM_INFORMATION = {
|
||||
"name": "gw-02.home.manivong.de",
|
||||
"versions": ["OPNsense 26.7.7-amd64", "FreeBSD 15.1-RELEASE-p4", "OpenSSL 3.5.9"],
|
||||
}
|
||||
STATUS_26_7 = {"metadata": {"translations": {}}, "subsystems": {}}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def driver() -> OPNsenseDriver:
|
||||
with patch("napalm_opnsense.opnsense.requests.Session"):
|
||||
return OPNsenseDriver(hostname="fw", username="k", password="s")
|
||||
|
||||
|
||||
def _answers(driver, system_information=SYSTEM_INFORMATION, status=STATUS_26_7, overview=OVERVIEW):
|
||||
def get(path: str):
|
||||
if path == "/api/diagnostics/system/system_information":
|
||||
if isinstance(system_information, Exception):
|
||||
raise system_information
|
||||
return system_information
|
||||
if path == "/api/core/system/status":
|
||||
return status
|
||||
if path == "/api/interfaces/overview/export":
|
||||
return overview
|
||||
raise AssertionError(f"unexpected GET {path}")
|
||||
|
||||
driver._get = get
|
||||
return driver
|
||||
|
||||
|
||||
class TestIdentity:
|
||||
def test_the_name_is_the_short_host_and_the_fqdn_the_whole(self, driver):
|
||||
facts = _answers(driver).get_facts()
|
||||
assert (facts["hostname"], facts["fqdn"]) == ("gw-02", "gw-02.home.manivong.de")
|
||||
|
||||
def test_the_version_comes_without_the_architecture(self, driver):
|
||||
assert _answers(driver).get_facts()["os_version"] == "26.7.7"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"line, version",
|
||||
[
|
||||
("OPNsense 26.7.7_1-aarch64", "26.7.7_1"),
|
||||
("OPNsense 25.1", "25.1"),
|
||||
("OPNsense 24.7.12-i386", "24.7.12"),
|
||||
],
|
||||
)
|
||||
def test_version_spellings(self, driver, line, version):
|
||||
info = {"name": "fw", "versions": [line]}
|
||||
assert _answers(driver, system_information=info).get_facts()["os_version"] == version
|
||||
|
||||
def test_a_name_without_a_domain_is_both(self, driver):
|
||||
facts = _answers(driver, system_information={"name": "fw", "versions": []}).get_facts()
|
||||
assert (facts["hostname"], facts["fqdn"]) == ("fw", "fw")
|
||||
|
||||
def test_without_system_information_the_status_answer_still_counts(self, driver):
|
||||
"""An older OPNsense, or an API key without the diagnostics privilege."""
|
||||
status = {"hostname": "opnsense01", "version": "24.7"}
|
||||
facts = _answers(driver, system_information=RuntimeError("403"), status=status).get_facts()
|
||||
assert (facts["hostname"], facts["os_version"]) == ("opnsense01", "24.7")
|
||||
|
||||
def test_nothing_known_is_empty_and_unknown(self, driver):
|
||||
facts = _answers(driver, system_information={}).get_facts()
|
||||
assert (facts["hostname"], facts["fqdn"], facts["os_version"]) == ("", "", "unknown")
|
||||
|
||||
|
||||
class TestHeldAddresses:
|
||||
def test_the_master_serves_its_carp_addresses(self, driver):
|
||||
result = _answers(driver).get_interfaces_ip()
|
||||
assert set(result["em0"]["ipv4"]) == {"192.0.2.2", "192.0.2.1"}
|
||||
assert result["em0"]["ipv4"]["192.0.2.1"] == {"prefix_length": 24}
|
||||
assert set(result["vlan0040"]["ipv4"]) == {
|
||||
"198.51.100.2",
|
||||
"198.51.100.1",
|
||||
"198.51.100.140",
|
||||
}
|
||||
|
||||
def test_a_backup_serves_its_own_address_only(self, driver):
|
||||
backup = json.loads(json.dumps(OVERVIEW).replace('"MASTER"', '"BACKUP"'))
|
||||
result = _answers(driver, overview=backup).get_interfaces_ip()
|
||||
assert set(result["em0"]["ipv4"]) == {"192.0.2.2"}
|
||||
assert set(result["vlan0040"]["ipv4"]) == {"198.51.100.2"}
|
||||
|
||||
def test_a_vhid_that_is_not_up_is_not_served(self, driver):
|
||||
result = _answers(driver).get_interfaces_ip()
|
||||
assert set(result["vlan0011"]["ipv4"]) == {"203.0.113.2"}
|
||||
|
||||
def test_a_held_ipv6_carp_address_is_served(self, driver):
|
||||
result = _answers(driver).get_interfaces_ip()
|
||||
assert "2001:db8:8::1" in result["em0"]["ipv6"]
|
||||
|
||||
def test_an_address_without_a_vhid_is_left_as_before(self, driver):
|
||||
"""Only CARP addresses are added; a plain alias is a separate question."""
|
||||
result = _answers(driver).get_interfaces_ip()
|
||||
assert set(result["vlan0020"]["ipv4"]) == {"192.0.2.130"}
|
||||
|
||||
def test_without_a_primary_address_the_list_still_leaves_out_a_backup(self, driver):
|
||||
overview = [
|
||||
{
|
||||
"device": "vlan0099",
|
||||
"ipv4": [
|
||||
{"ipaddr": "192.0.2.66/26"},
|
||||
{"ipaddr": "192.0.2.65/26", "vhid": "199", "status": "BACKUP"},
|
||||
],
|
||||
}
|
||||
]
|
||||
result = _answers(driver, overview=overview).get_interfaces_ip()
|
||||
assert set(result["vlan0099"]["ipv4"]) == {"192.0.2.66"}
|
||||
Reference in new issue
Block a user