840 lines
31 KiB
Python
840 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, Dict, List, Optional
|
|
from urllib.parse import urljoin
|
|
|
|
import requests
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric import padding as asym_padding, rsa
|
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
from cryptography.hazmat.backends import default_backend
|
|
|
|
from napalm.base.exceptions import ConnectionClosedException, ConnectionException
|
|
|
|
from napalm_device_types import ResidentialGatewayDriver
|
|
from napalm_device_types.models import (
|
|
DeviceActionResultDict,
|
|
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"
|
|
|
|
_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: Optional[Dict[str, Any]] = 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: Optional[requests.Session] = None
|
|
self._sessionkey: Optional[str] = None
|
|
self._crypto: Optional[ZyxelCrypto] = 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) -> Optional[dict]:
|
|
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) -> Optional[str]:
|
|
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: Optional[dict] = None) -> Optional[dict]:
|
|
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) -> Optional[str]:
|
|
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, Optional[str]]:
|
|
"""Not supported on Zyxel VMG."""
|
|
return {}
|
|
|
|
def get_ntp_servers(self) -> Dict[str, Optional[str]]:
|
|
"""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: Optional[int] = 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: Optional[str] = None, config: Optional[str] = None
|
|
) -> None:
|
|
"""Not supported on Zyxel VMG."""
|
|
raise NotImplementedError("Zyxel VMG does not support config replacement via NAPALM")
|
|
|
|
def load_merge_candidate(
|
|
self, filename: Optional[str] = None, config: Optional[str] = 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(self) -> DeviceActionResultDict:
|
|
"""Reboot the device via DAL."""
|
|
result = self._dal_query("REBOOT", {"Command": "reboot"})
|
|
if result is not None and result.get("result") == "ZCFG_SUCCESS":
|
|
return {"success": True, "action": "reboot", "message": "Device is rebooting"}
|
|
return {"success": False, "action": "reboot", "message": "Reboot command failed"}
|
|
|
|
def run_device_action(self, action: str) -> DeviceActionResultDict:
|
|
"""Run a device action such as reboot or WiFi restart."""
|
|
result: DeviceActionResultDict = {
|
|
"success": False,
|
|
"action": action,
|
|
"message": "",
|
|
}
|
|
try:
|
|
if action == "reboot":
|
|
return self.reboot()
|
|
elif action.startswith("restart_wifi"):
|
|
band = "both"
|
|
if ":" in action:
|
|
band = action.split(":", 1)[1]
|
|
dal = self._dal_query("WIFI_RESTART", {"Band": band})
|
|
if dal is not None and dal.get("result") == "ZCFG_SUCCESS":
|
|
result["success"] = True
|
|
result["message"] = f"WiFi restarting ({band})"
|
|
else:
|
|
result["message"] = "WiFi restart command failed"
|
|
elif action == "get_diagnostics":
|
|
diag = self._dal_query("DIAGNOSTICS", {})
|
|
if diag:
|
|
result["success"] = True
|
|
result["message"] = json.dumps(diag)
|
|
else:
|
|
result["message"] = "Diagnostics not available"
|
|
else:
|
|
result["message"] = f"Unknown action: {action}"
|
|
except Exception as e:
|
|
result["message"] = f"Action '{action}' failed: {e}"
|
|
return result
|