Mechanical: ruff format, and ruff check --fix with the rule set every NAPALM driver uses (E, F, I, UP, B at line length 100, Python 3.10). What ruff cannot fix comes in the next commit.
829 lines
31 KiB
Python
829 lines
31 KiB
Python
"""NAPALM driver for Zyxel VMG series residential gateways.
|
|
|
|
Provides device management via HTTP(S) API with AES/RSA encrypted
|
|
payloads through the Zyxel DAL protocol.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import os
|
|
import re
|
|
import socket
|
|
from typing import Any
|
|
from urllib.parse import urljoin
|
|
|
|
import requests
|
|
from cryptography.hazmat.backends import default_backend
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric import padding as asym_padding
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
from napalm.base.exceptions import ConnectionClosedException, ConnectionException
|
|
from napalm_device_types import FingerprintRule, ResidentialGatewayDriver
|
|
from napalm_device_types.models import (
|
|
HostDict,
|
|
NATTranslationDict,
|
|
PortForwardDict,
|
|
RadioStatusDict,
|
|
SSIDDict,
|
|
VPNTunnelDict,
|
|
WANStatusDict,
|
|
WirelessClientDict,
|
|
)
|
|
|
|
|
|
class ZyxelCrypto:
|
|
"""AES-256-CBC encryption wrapper for Zyxel DAL protocol payloads."""
|
|
|
|
_AES_KEY_SIZE = 32
|
|
_IV_SIZE = 16
|
|
_DEFAULT_IV = b"\x42" * 16
|
|
|
|
def __init__(self) -> None:
|
|
self.aes_key = os.urandom(self._AES_KEY_SIZE)
|
|
|
|
def encrypt_json(self, data: dict) -> dict:
|
|
"""Encrypt a dict payload as AES-256-CBC and return base64-encoded content/iv."""
|
|
iv = self._DEFAULT_IV
|
|
payload = json.dumps(data).encode("ascii")
|
|
pad_len = self._AES_KEY_SIZE - (len(payload) % self._AES_KEY_SIZE)
|
|
payload += bytes([pad_len] * pad_len)
|
|
cipher = Cipher(algorithms.AES(self.aes_key), modes.CBC(iv), backend=default_backend())
|
|
encryptor = cipher.encryptor()
|
|
content = encryptor.update(payload) + encryptor.finalize()
|
|
return {
|
|
"content": base64.b64encode(content).decode("ascii"),
|
|
"iv": base64.b64encode(iv).decode("ascii"),
|
|
"key": "",
|
|
}
|
|
|
|
def decrypt_response(self, data: dict) -> dict:
|
|
"""Decrypt an AES-256-CBC encrypted response payload, or pass through if not encrypted."""
|
|
if not isinstance(data, dict) or "content" not in data:
|
|
return data
|
|
iv = base64.b64decode(data["iv"])[: self._IV_SIZE]
|
|
content = base64.b64decode(data["content"])
|
|
cipher = Cipher(algorithms.AES(self.aes_key), modes.CBC(iv), backend=default_backend())
|
|
decryptor = cipher.decryptor()
|
|
padded = decryptor.update(content) + decryptor.finalize()
|
|
pad_len = padded[-1]
|
|
plain = padded[:-pad_len] if 0 < pad_len <= self._AES_KEY_SIZE else padded
|
|
return json.loads(plain.decode("ascii"))
|
|
|
|
def encrypt_aes_key_with_rsa(self, pubkey_pem: str) -> str:
|
|
"""Encrypt the AES key with the device's RSA public key for key exchange."""
|
|
pubkey = serialization.load_pem_public_key(
|
|
pubkey_pem.encode("ascii"), backend=default_backend()
|
|
)
|
|
if not isinstance(pubkey, rsa.RSAPublicKey):
|
|
raise ValueError("Not an RSA public key")
|
|
encrypted = pubkey.encrypt(
|
|
base64.b64encode(self.aes_key),
|
|
asym_padding.PKCS1v15(),
|
|
)
|
|
return base64.b64encode(encrypted).decode("ascii")
|
|
|
|
|
|
class ZyxelVMGDriver(ResidentialGatewayDriver):
|
|
"""NAPALM driver for Zyxel VMG residential gateway devices via DAL protocol."""
|
|
|
|
VENDOR = "Zyxel"
|
|
DRIVER_NAME = "zyxel"
|
|
SNMP_OBJECT_ID_PREFIX = "1.3.6.1.4.1.890"
|
|
OUI_PREFIXES = ["00:13:49", "1C:74:0D", "C8:6C:87", "B0:B2:DC", "28:28:5D"]
|
|
HTTP_FINGERPRINT = [
|
|
FingerprintRule("zyxel", weight=7.0, mandatory=True),
|
|
FingerprintRule("gs1920", weight=6.0),
|
|
FingerprintRule("gs2210", weight=6.0),
|
|
]
|
|
|
|
_LOGIN_PATH = "UserLogin"
|
|
_LOGOUT_PATH = "cgi-bin/UserLogout"
|
|
_RSA_KEY_PATH = "getRSAPublickKey"
|
|
_BASIC_INFO_PATH = "getBasicInformation"
|
|
_DAL_PATH = "cgi-bin/DAL"
|
|
|
|
def __init__(
|
|
self,
|
|
hostname: str,
|
|
username: str,
|
|
password: str,
|
|
timeout: int = 60,
|
|
optional_args: dict[str, Any] | None = None,
|
|
) -> None:
|
|
self.hostname = hostname
|
|
self.username = username
|
|
self.password = password
|
|
self.timeout = timeout
|
|
self.optional_args = optional_args or {}
|
|
self.force_no_enable = True
|
|
self.use_canonical_interface = False
|
|
|
|
self.use_tls = bool(self.optional_args.get("use_tls", False))
|
|
self.port = int(self.optional_args.get("port") or (443 if self.use_tls else 80))
|
|
self.verify = bool(self.optional_args.get("verify", False))
|
|
|
|
self._session: requests.Session | None = None
|
|
self._sessionkey: str | None = None
|
|
self._crypto: ZyxelCrypto | None = None
|
|
self._base_url: str = f"{'https' if self.use_tls else 'http'}://{self.hostname}:{self.port}"
|
|
|
|
def _url(self, path: str) -> str:
|
|
return urljoin(f"{self._base_url}/", path)
|
|
|
|
def open(self) -> None:
|
|
"""Open a connection and authenticate with the Zyxel device."""
|
|
self._session = requests.Session()
|
|
self._session.verify = self.verify
|
|
self._session.timeout = self.timeout
|
|
self._session.trust_env = False
|
|
|
|
info = self._get_basic_info()
|
|
if info is None:
|
|
raise ConnectionException(f"Cannot reach Zyxel device at {self._base_url}")
|
|
self._device_info = info
|
|
|
|
crypto = ZyxelCrypto()
|
|
pubkey = self._get_rsa_pubkey()
|
|
if pubkey is None:
|
|
raise ConnectionException(f"Cannot obtain RSA public key from {self._base_url}")
|
|
enc_key = crypto.encrypt_aes_key_with_rsa(pubkey)
|
|
|
|
login_data = {
|
|
"Input_Account": self.username,
|
|
"Input_Passwd": base64.b64encode(self.password.encode("ascii")).decode("ascii"),
|
|
"RememberPassword": 0,
|
|
"SHA512_password": False,
|
|
}
|
|
|
|
enc_payload = crypto.encrypt_json(login_data)
|
|
enc_payload["key"] = enc_key
|
|
|
|
try:
|
|
resp = self._session.post(
|
|
self._url(self._LOGIN_PATH),
|
|
json=enc_payload,
|
|
)
|
|
resp.raise_for_status()
|
|
result = crypto.decrypt_response(resp.json())
|
|
except (requests.RequestException, json.JSONDecodeError, TypeError, KeyError) as e:
|
|
raise ConnectionException(f"Login failed at {self._base_url}: {e}")
|
|
|
|
if result.get("result") != "ZCFG_SUCCESS":
|
|
raise ConnectionException(
|
|
f"Login rejected by {self._base_url}: {result.get('result', 'unknown')}"
|
|
)
|
|
|
|
self._sessionkey = result.get("sessionkey")
|
|
self._crypto = crypto
|
|
|
|
def close(self) -> None:
|
|
"""Close the session and log out from the device."""
|
|
if self._session is not None and self._sessionkey is not None:
|
|
try:
|
|
self._session.get(
|
|
self._url(self._LOGOUT_PATH),
|
|
params={"sessionKey": self._sessionkey},
|
|
)
|
|
except requests.RequestException:
|
|
pass
|
|
self._session = None
|
|
self._sessionkey = None
|
|
self._crypto = None
|
|
self._device_info = None
|
|
|
|
def is_alive(self) -> dict[str, bool]:
|
|
"""Check if the device is reachable via TCP connection."""
|
|
try:
|
|
sock = socket.create_connection((self.hostname, self.port), timeout=5)
|
|
sock.close()
|
|
return {"is_alive": True}
|
|
except OSError:
|
|
return {"is_alive": False}
|
|
|
|
def _get_basic_info(self) -> dict | None:
|
|
if self._session is None:
|
|
return None
|
|
try:
|
|
resp = self._session.get(self._url(self._BASIC_INFO_PATH))
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
except (requests.RequestException, json.JSONDecodeError, TypeError):
|
|
return None
|
|
|
|
def _get_rsa_pubkey(self) -> str | None:
|
|
if self._session is None:
|
|
return None
|
|
try:
|
|
resp = self._session.get(self._url(self._RSA_KEY_PATH))
|
|
resp.raise_for_status()
|
|
return resp.json().get("RSAPublicKey")
|
|
except (requests.RequestException, json.JSONDecodeError, TypeError):
|
|
return None
|
|
|
|
def _dal_query(self, oid: str, data: dict | None = None) -> dict | None:
|
|
if self._session is None or self._sessionkey is None or self._crypto is None:
|
|
raise ConnectionClosedException("Not connected")
|
|
if data is None:
|
|
data = {}
|
|
try:
|
|
enc = self._crypto.encrypt_json(data)
|
|
resp = self._session.put(
|
|
self._url(self._DAL_PATH),
|
|
params={"oid": oid, "sessionkey": self._sessionkey},
|
|
json=enc,
|
|
)
|
|
resp.raise_for_status()
|
|
return self._crypto.decrypt_response(resp.json())
|
|
except Exception:
|
|
return None
|
|
|
|
def _fetch_page(self, path: str) -> str | None:
|
|
if self._session is None or self._sessionkey is None:
|
|
raise ConnectionClosedException("Not connected")
|
|
try:
|
|
resp = self._session.get(
|
|
self._url(path),
|
|
params={"sessionKey": self._sessionkey},
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.text
|
|
except requests.RequestException:
|
|
return None
|
|
|
|
def get_facts(self) -> dict[str, Any]:
|
|
"""Return device facts such as model, serial, OS version, and uptime."""
|
|
info = self._device_info or {}
|
|
try:
|
|
iface_list = list(self.get_interfaces().keys())
|
|
except Exception:
|
|
iface_list = []
|
|
return {
|
|
"vendor": self.VENDOR,
|
|
"model": info.get("ModelName", ""),
|
|
"serial_number": info.get("SerialNumber", ""),
|
|
"os_version": info.get("SoftwareVersion", ""),
|
|
"hostname": info.get("HostName", self.hostname),
|
|
"fqdn": self.hostname,
|
|
"uptime": int(info.get("SystemUpTime", 0) or 0),
|
|
"interface_list": iface_list,
|
|
}
|
|
|
|
def get_interfaces(self) -> dict[str, dict[str, Any]]:
|
|
"""Return WAN, LAN, and WiFi interfaces from device info."""
|
|
ifaces: dict[str, dict[str, Any]] = {}
|
|
info = self._device_info or {}
|
|
wan_ip = info.get("WanIp", "")
|
|
mac = info.get("MacAddress", "")
|
|
ifaces["wan"] = {
|
|
"is_up": bool(wan_ip),
|
|
"is_enabled": True,
|
|
"description": "WAN (VDSL)",
|
|
"last_flapped": -1.0,
|
|
"mac_address": mac or "",
|
|
"speed": 1000,
|
|
"mtu": 1500,
|
|
}
|
|
ifaces["lan"] = {
|
|
"is_up": True,
|
|
"is_enabled": True,
|
|
"description": "LAN (Ethernet)",
|
|
"last_flapped": -1.0,
|
|
"mac_address": mac or "",
|
|
"speed": 1000,
|
|
"mtu": 1500,
|
|
}
|
|
for band in ("2.4GHz", "5GHz"):
|
|
key = f"wlan_{band.lower().replace('.', '_').replace('ghz', 'ghz')}"
|
|
ifaces[key] = {
|
|
"is_up": True,
|
|
"is_enabled": True,
|
|
"description": f"WiFi {band}",
|
|
"last_flapped": -1.0,
|
|
"mac_address": "",
|
|
"speed": 0,
|
|
"mtu": 1500,
|
|
}
|
|
return ifaces
|
|
|
|
def get_interfaces_ip(self) -> dict[str, dict[str, Any]]:
|
|
"""Return IP addresses of WAN and LAN interfaces."""
|
|
info = self._device_info or {}
|
|
wan_ip = info.get("WanIp", "")
|
|
lan_ip = info.get("LanIp", self.hostname)
|
|
result: dict[str, dict[str, Any]] = {}
|
|
if wan_ip:
|
|
result["wan"] = {"ipv4": {wan_ip: {"prefix_length": 24}}, "ipv6": {}}
|
|
result["lan"] = {"ipv4": {lan_ip: {"prefix_length": 24}}, "ipv6": {}}
|
|
return result
|
|
|
|
def get_arp_table(self, vrf: str = "") -> list[dict[str, Any]]:
|
|
"""Return the ARP table derived from DHCP host entries."""
|
|
arp: list[dict[str, Any]] = []
|
|
for host in self.get_hosts():
|
|
if not host["is_active"] or not host["mac"] or not host["ip"]:
|
|
continue
|
|
arp.append(
|
|
{
|
|
"interface": host["interface_type"],
|
|
"mac": host["mac"],
|
|
"ip": host["ip"],
|
|
"age": 0.0,
|
|
}
|
|
)
|
|
return arp
|
|
|
|
def get_lldp_neighbors(self) -> dict[str, Any]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return {}
|
|
|
|
def get_lldp_neighbors_detail(self, interface: str = "") -> dict[str, Any]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return {}
|
|
|
|
def get_config(
|
|
self,
|
|
retrieve: str = "all",
|
|
full: bool = False,
|
|
sanitized: bool = False,
|
|
format: str = "text",
|
|
) -> dict[str, Any]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return {}
|
|
|
|
def get_environment(self) -> dict[str, Any]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return {}
|
|
|
|
def get_optics(self) -> dict[str, Any]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return {}
|
|
|
|
def get_ntp_peers(self) -> dict[str, str | None]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return {}
|
|
|
|
def get_ntp_servers(self) -> dict[str, str | None]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return {}
|
|
|
|
def get_ntp_stats(self) -> list[dict[str, Any]]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return []
|
|
|
|
def ping(
|
|
self,
|
|
destination: str,
|
|
source: str = "",
|
|
ttl: int = 255,
|
|
timeout: int = 2,
|
|
size: int = 100,
|
|
count: int = 5,
|
|
vrf: str = "",
|
|
source_interface: str = "",
|
|
) -> dict[str, Any]:
|
|
"""Ping a destination via the device's DAL diagnostics."""
|
|
result = self._dal_query(
|
|
"PINGTEST",
|
|
{
|
|
"ProtocolVersion": "IPv4",
|
|
"Host": destination,
|
|
"DiagnosticsState": "Requested",
|
|
"DNSServer": "",
|
|
"NumberOfRepetitions": count,
|
|
"type": 0,
|
|
},
|
|
)
|
|
if result is None:
|
|
return {"success": {}, "error": "Ping not supported via DAL"}
|
|
return {
|
|
"success": {
|
|
"results": [{"rtt": 0}],
|
|
"probes_sent": count,
|
|
"probes_received": count,
|
|
},
|
|
"error": "",
|
|
}
|
|
|
|
def traceroute(
|
|
self,
|
|
destination: str,
|
|
source: str = "",
|
|
ttl: int = 255,
|
|
timeout: int = 2,
|
|
vrf: str = "",
|
|
) -> dict[str, Any]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return {"success": {}, "error": "Not supported on Zyxel VMG"}
|
|
|
|
def get_route_to(
|
|
self,
|
|
destination: str = "",
|
|
protocol: str = "",
|
|
longer: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return {}
|
|
|
|
def get_snmp_information(self) -> dict[str, Any]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return {}
|
|
|
|
def get_users(self) -> dict[str, Any]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return {}
|
|
|
|
def get_network_instances(self, name: str = "") -> dict[str, Any]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return {}
|
|
|
|
def get_bgp_config(self, group: str = "", neighbor: str = "") -> dict[str, Any]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return {}
|
|
|
|
def get_bgp_neighbors(self) -> dict[str, Any]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return {}
|
|
|
|
def get_bgp_neighbors_detail(self, neighbor_address: str = "") -> dict[str, Any]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return {}
|
|
|
|
def get_probes_config(self) -> dict[str, Any]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return {}
|
|
|
|
def get_probes_results(self) -> dict[str, Any]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return {}
|
|
|
|
def get_mac_address_table(self) -> list[dict[str, Any]]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return []
|
|
|
|
def get_firewall_policies(self) -> dict[str, Any]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return {}
|
|
|
|
def cli(self, commands: list[str], encoding: str = "text") -> dict[str, Any]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return {}
|
|
|
|
def commit_config(self, message: str = "", revert_in: int | None = None) -> None:
|
|
"""Not supported on Zyxel VMG."""
|
|
pass
|
|
|
|
def discard_config(self) -> None:
|
|
"""Not supported on Zyxel VMG."""
|
|
pass
|
|
|
|
def load_replace_candidate(
|
|
self, filename: str | None = None, config: str | None = None
|
|
) -> None:
|
|
"""Not supported on Zyxel VMG."""
|
|
raise NotImplementedError("Zyxel VMG does not support config replacement via NAPALM")
|
|
|
|
def load_merge_candidate(self, filename: str | None = None, config: str | None = None) -> None:
|
|
"""Not supported on Zyxel VMG."""
|
|
raise NotImplementedError("Zyxel VMG does not support config merge via NAPALM")
|
|
|
|
def compare_config(self) -> str:
|
|
"""Not supported on Zyxel VMG."""
|
|
return ""
|
|
|
|
def rollback(self) -> None:
|
|
"""Not supported on Zyxel VMG."""
|
|
pass
|
|
|
|
def get_wan_status(self) -> WANStatusDict:
|
|
"""Return WAN connection status from cached device info."""
|
|
info = self._device_info or {}
|
|
wan_ip = info.get("WanIp", "")
|
|
return {
|
|
"connection_type": info.get("WanAccessType", "VDSL"),
|
|
"is_connected": bool(wan_ip),
|
|
"external_ip": wan_ip,
|
|
"uptime": int(info.get("SystemUpTime", 0) or 0),
|
|
"bytes_sent": 0,
|
|
"bytes_received": 0,
|
|
"max_bitrate_up": int(info.get("MaxUpstream", 0) or 0) // 1000,
|
|
"max_bitrate_down": int(info.get("MaxDownstream", 0) or 0) // 1000,
|
|
"link_status": "Up" if wan_ip else "Down",
|
|
}
|
|
|
|
def get_port_forwards(self) -> list[PortForwardDict]:
|
|
"""Return port forwarding rules from DAL or HTML fallback."""
|
|
html = self._fetch_page("cgi-bin/DAL?oid=NAT_PORTMAP")
|
|
if html is not None:
|
|
try:
|
|
data = json.loads(html)
|
|
return self._parse_port_forwards_dal(data)
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
html = self._fetch_page("Status.htm")
|
|
if html is not None:
|
|
return self._parse_port_forwards_html(html)
|
|
return []
|
|
|
|
def _parse_port_forwards_dal(self, data: dict) -> list[PortForwardDict]:
|
|
forwards: list[PortForwardDict] = []
|
|
rules = data.get("rules", data.get("NATPortMap", data.get("entries", [])))
|
|
if isinstance(rules, dict):
|
|
rules = [rules]
|
|
if not isinstance(rules, list):
|
|
return forwards
|
|
for rule in rules:
|
|
if not isinstance(rule, dict):
|
|
continue
|
|
fwd: PortForwardDict = {
|
|
"name": rule.get("Description", rule.get("name", "")),
|
|
"protocol": rule.get("Protocol", rule.get("protocol", "TCP")),
|
|
"external_port": int(rule.get("ExternalPort", rule.get("external_port", 0)) or 0),
|
|
"internal_ip": rule.get("InternalIP", rule.get("internal_ip", "")),
|
|
"internal_port": int(rule.get("InternalPort", rule.get("internal_port", 0)) or 0),
|
|
"enabled": bool(rule.get("Enabled", rule.get("enabled", True))),
|
|
}
|
|
forwards.append(fwd)
|
|
return forwards
|
|
|
|
_PORT_FORWARD_RE = re.compile(
|
|
r"(?P<name>[^|]+?)\s*\|\s*(?P<proto>TCP|UDP|BOTH)\s*\|\s*"
|
|
r"(?P<ext_port>\d+)\s*\|\s*(?P<int_ip>[0-9.]+)\s*\|\s*"
|
|
r"(?P<int_port>\d+)",
|
|
re.I,
|
|
)
|
|
|
|
def _parse_port_forwards_html(self, html: str) -> list[PortForwardDict]:
|
|
forwards: list[PortForwardDict] = []
|
|
for match in self._PORT_FORWARD_RE.finditer(html):
|
|
forwards.append(
|
|
{
|
|
"name": match.group("name").strip(),
|
|
"protocol": match.group("proto").upper(),
|
|
"external_port": int(match.group("ext_port")),
|
|
"internal_ip": match.group("int_ip"),
|
|
"internal_port": int(match.group("int_port")),
|
|
"enabled": True,
|
|
}
|
|
)
|
|
return forwards
|
|
|
|
def get_hosts(self) -> list[HostDict]:
|
|
"""Return DHCP host entries from DAL or HTML fallback."""
|
|
html = self._fetch_page("cgi-bin/DAL?oid=LAN_HOSTS")
|
|
if html is not None:
|
|
try:
|
|
data = json.loads(html)
|
|
return self._parse_hosts_dal(data)
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
html = self._fetch_page("dhcp.htm")
|
|
if html is not None:
|
|
return self._parse_hosts_html(html)
|
|
return []
|
|
|
|
def _parse_hosts_dal(self, data: dict) -> list[HostDict]:
|
|
hosts: list[HostDict] = []
|
|
entries = data.get("hosts", data.get("LANHosts", data.get("entries", [])))
|
|
if isinstance(entries, dict):
|
|
entries = [entries]
|
|
if not isinstance(entries, list):
|
|
return hosts
|
|
for entry in entries:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
host: HostDict = {
|
|
"mac": (entry.get("MACAddress", entry.get("mac", "")) or "").lower(),
|
|
"ip": entry.get("IPAddress", entry.get("ip", "")),
|
|
"hostname": entry.get("HostName", entry.get("hostname", "")),
|
|
"interface_type": entry.get("InterfaceType", entry.get("interface_type", "lan")),
|
|
"is_active": bool(entry.get("Active", entry.get("active", True))),
|
|
}
|
|
lease = entry.get("LeaseTime", entry.get("lease_time"))
|
|
if lease is not None:
|
|
host["lease_time_remaining"] = int(lease)
|
|
hosts.append(host)
|
|
return hosts
|
|
|
|
_HOST_RE = re.compile(
|
|
r"(?P<ip>[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)"
|
|
r"\s+"
|
|
r"(?P<mac>[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})"
|
|
r"\s+"
|
|
r"(?P<hostname>\S+)?",
|
|
)
|
|
|
|
def _parse_hosts_html(self, html: str) -> list[HostDict]:
|
|
hosts: list[HostDict] = []
|
|
for match in self._HOST_RE.finditer(html):
|
|
hosts.append(
|
|
{
|
|
"mac": match.group("mac").lower(),
|
|
"ip": match.group("ip"),
|
|
"hostname": match.group("hostname") or "",
|
|
"interface_type": "lan",
|
|
"is_active": True,
|
|
}
|
|
)
|
|
return hosts
|
|
|
|
def get_nat_translations(self) -> list[NATTranslationDict]:
|
|
"""Return NAT translations derived from enabled port forwards."""
|
|
wan_ip = (self._device_info or {}).get("WanIp", "")
|
|
translations: list[NATTranslationDict] = []
|
|
for forward in self.get_port_forwards():
|
|
if not forward["enabled"]:
|
|
continue
|
|
translations.append(
|
|
{
|
|
"protocol": forward["protocol"].lower(),
|
|
"inside_local": f"{forward['internal_ip']}:{forward['internal_port']}",
|
|
"inside_global": f"{wan_ip}:{forward['external_port']}",
|
|
"outside_local": "0.0.0.0:0",
|
|
"outside_global": "0.0.0.0:0",
|
|
"age": 0.0,
|
|
}
|
|
)
|
|
return translations
|
|
|
|
def get_vpn_tunnels(self) -> dict[str, VPNTunnelDict]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return {}
|
|
|
|
def get_wireless_clients(self) -> list[WirelessClientDict]:
|
|
"""Return connected wireless clients from DAL or HTML fallback."""
|
|
html = self._fetch_page("cgi-bin/DAL?oid=WIFI_CLIENTS")
|
|
if html is not None:
|
|
try:
|
|
data = json.loads(html)
|
|
return self._parse_wireless_clients_dal(data)
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
html = self._fetch_page("wireless.htm")
|
|
if html is not None:
|
|
return self._parse_wireless_clients_html(html)
|
|
return []
|
|
|
|
def _parse_wireless_clients_dal(self, data: dict) -> list[WirelessClientDict]:
|
|
clients: list[WirelessClientDict] = []
|
|
entries = data.get("clients", data.get("assoc", data.get("entries", [])))
|
|
if isinstance(entries, dict):
|
|
entries = [entries]
|
|
if not isinstance(entries, list):
|
|
return clients
|
|
for entry in entries:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
client: WirelessClientDict = {
|
|
"mac": (entry.get("MACAddress", entry.get("mac", "")) or "").lower(),
|
|
"ssid": entry.get("SSID", entry.get("ssid", "")),
|
|
"radio": entry.get("Radio", entry.get("radio", "")),
|
|
"signal": int(entry.get("Signal", entry.get("signal", 0)) or 0),
|
|
"noise": int(entry.get("Noise", entry.get("noise", 0)) or 0),
|
|
"tx_rate": float(entry.get("TxRate", entry.get("tx_rate", 0)) or 0),
|
|
"rx_rate": float(entry.get("RxRate", entry.get("rx_rate", 0)) or 0),
|
|
"uptime": int(entry.get("Uptime", entry.get("uptime", 0)) or 0),
|
|
}
|
|
ip = entry.get("IPAddress", entry.get("ip", ""))
|
|
if ip:
|
|
client["ip"] = ip
|
|
clients.append(client)
|
|
return clients
|
|
|
|
_WIFI_CLIENT_RE = re.compile(
|
|
r"(?P<mac>[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})"
|
|
r"\s+"
|
|
r"(?P<ip>[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)"
|
|
r"\s+"
|
|
r"(?P<rssi>-?\d+)",
|
|
)
|
|
|
|
def _parse_wireless_clients_html(self, html: str) -> list[WirelessClientDict]:
|
|
clients: list[WirelessClientDict] = []
|
|
for match in self._WIFI_CLIENT_RE.finditer(html):
|
|
clients.append(
|
|
{
|
|
"mac": match.group("mac").lower(),
|
|
"ssid": "",
|
|
"radio": "wlan0",
|
|
"signal": int(match.group("rssi")),
|
|
"noise": 0,
|
|
"tx_rate": 0.0,
|
|
"rx_rate": 0.0,
|
|
"uptime": 0,
|
|
"ip": match.group("ip"),
|
|
}
|
|
)
|
|
return clients
|
|
|
|
def get_ssids(self) -> dict[str, SSIDDict]:
|
|
"""Return WiFi SSID configurations from DAL or HTML fallback."""
|
|
html = self._fetch_page("cgi-bin/DAL?oid=WIFI_SSIDS")
|
|
if html is not None:
|
|
try:
|
|
data = json.loads(html)
|
|
return self._parse_ssids_dal(data)
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
html = self._fetch_page("wireless.htm")
|
|
if html is not None:
|
|
return self._parse_ssids_html(html)
|
|
return {}
|
|
|
|
def _parse_ssids_dal(self, data: dict) -> dict[str, SSIDDict]:
|
|
ssids: dict[str, SSIDDict] = {}
|
|
entries = data.get("ssids", data.get("entries", []))
|
|
if isinstance(entries, dict):
|
|
entries = [entries]
|
|
if not isinstance(entries, list):
|
|
return ssids
|
|
for entry in entries:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
ssid_name = (entry.get("SSID", entry.get("ssid", "")) or "").strip()
|
|
if not ssid_name:
|
|
continue
|
|
ssids[ssid_name] = {
|
|
"enabled": bool(entry.get("Enabled", entry.get("enabled", True))),
|
|
"radio": entry.get("Radio", entry.get("radio", "")),
|
|
"bssid": (entry.get("BSSID", entry.get("bssid", "")) or "").lower(),
|
|
"encryption": entry.get("Encryption", entry.get("encryption", "")),
|
|
"hidden": bool(entry.get("Hidden", entry.get("hidden", False))),
|
|
"clients": int(entry.get("Clients", entry.get("clients", 0)) or 0),
|
|
}
|
|
return ssids
|
|
|
|
def _parse_ssids_html(self, html: str) -> dict[str, SSIDDict]:
|
|
ssids: dict[str, SSIDDict] = {}
|
|
pattern = re.compile(r"SSID[^:]*:\s*(\S+)", re.I)
|
|
for match in pattern.finditer(html):
|
|
name = match.group(1)
|
|
if name and name not in ssids:
|
|
ssids[name] = {
|
|
"enabled": True,
|
|
"radio": "wlan0",
|
|
"bssid": "",
|
|
"encryption": "",
|
|
"hidden": False,
|
|
"clients": 0,
|
|
}
|
|
return ssids
|
|
|
|
def get_radio_status(self) -> dict[str, RadioStatusDict]:
|
|
"""Return WiFi radio status from DAL or HTML fallback."""
|
|
radios: dict[str, RadioStatusDict] = {}
|
|
html = self._fetch_page("cgi-bin/DAL?oid=WIFI_RADIO")
|
|
if html is not None:
|
|
try:
|
|
data = json.loads(html)
|
|
entries = data.get("radios", data.get("entries", []))
|
|
if isinstance(entries, dict):
|
|
entries = [entries]
|
|
for entry in entries if isinstance(entries, list) else []:
|
|
name = entry.get("Name", entry.get("name", ""))
|
|
channel = int(entry.get("Channel", entry.get("channel", 0)) or 0)
|
|
radios[name] = {
|
|
"enabled": bool(entry.get("Enabled", entry.get("enabled", True))),
|
|
"band": "2.4GHz" if channel <= 14 else "5GHz",
|
|
"channel": channel,
|
|
"channel_width": int(
|
|
entry.get("ChannelWidth", entry.get("channel_width", 0)) or 0
|
|
),
|
|
"tx_power": int(entry.get("TxPower", entry.get("tx_power", 0)) or 0),
|
|
"frequency": float(entry.get("Frequency", entry.get("frequency", 0)) or 0),
|
|
}
|
|
return radios
|
|
except (json.JSONDecodeError, TypeError, ValueError):
|
|
pass
|
|
html = self._fetch_page("wireless.htm")
|
|
if html is not None:
|
|
for band, pat in [("2.4GHz", r"2\.4[Gg]"), ("5GHz", r"5[Gg]")]:
|
|
if re.search(pat, html):
|
|
radios[band] = {
|
|
"enabled": True,
|
|
"band": band,
|
|
"channel": 0,
|
|
"channel_width": 0,
|
|
"tx_power": 0,
|
|
"frequency": 0.0,
|
|
}
|
|
return radios
|
|
|
|
def reboot_host(self) -> None:
|
|
"""Restart the gateway (``HostRebootMixin``); raises when it refuses."""
|
|
result = self._dal_query("REBOOT", {"Command": "reboot"})
|
|
if result is None or result.get("result") != "ZCFG_SUCCESS":
|
|
raise RuntimeError(f"the gateway refused to reboot: {result}")
|
|
|
|
def restart_wifi(self, band: str = "both") -> None:
|
|
"""Restart the radios of *band* (``2.4G``, ``5G`` or ``both``); raises when refused."""
|
|
result = self._dal_query("WIFI_RESTART", {"Band": band})
|
|
if result is None or result.get("result") != "ZCFG_SUCCESS":
|
|
raise RuntimeError(f"the gateway refused to restart Wi-Fi ({band}): {result}")
|
|
|
|
def get_diagnostics(self) -> dict:
|
|
"""What the gateway's diagnostics page reports; empty when it reports nothing."""
|
|
return self._dal_query("DIAGNOSTICS", {}) or {}
|