bug in zyxel

This commit is contained in:
2026-08-22 19:54:38 +02:00
parent 90671d0b9b
commit d43b375d73
6 changed files with 166 additions and 7 deletions
+8
View File
@@ -9,6 +9,14 @@ logs into the router's encrypted web API and polls its active LAN clients.
3. Test connectivity: `python service.py --once` 3. Test connectivity: `python service.py --once`
4. Run continuously: `python service.py` 4. Run continuously: `python service.py`
Check any MAC address directly through the Zyxel client with:
```bash
python lib/_zyxel.py --mac AA:BB:CC:DD:EE:FF
```
Omit `--mac` to check `arrival_detection.device.mac` from `service.yaml`.
An arrival is logged after the phone was confirmed absent and then present. It An arrival is logged after the phone was confirmed absent and then present. It
also calls `send_notification()` from `notify.py`; its message, ntfy topic, and also calls `send_notification()` from `notify.py`; its message, ntfy topic, and
timeout are configured under `arrival_detection.notification`. Set timeout are configured under `arrival_detection.notification`. Set
+36
View File
@@ -0,0 +1,36 @@
import requests
import re
from urllib.parse import urljoin
ROUTER = "https://192.168.178.2"
r = requests.get(ROUTER, verify=False, timeout=5)
scripts = re.findall(r'<script[^>]+src=["\']([^"\']+)', r.text)
for script in scripts:
if script.endswith("/app.js"):
url = urljoin(ROUTER, script)
print("Downloading:", url)
js = requests.get(url, verify=False, timeout=10).text
print("Size:", len(js), "bytes")
# Print strings containing things that are likely to be API endpoints
print("\nInteresting URLs/strings:\n")
patterns = [
r'["\']([^"\']*(?:api|cgi|device|client|status|dhcp|wifi|wireless|lan)[^"\']*)["\']',
]
found = set()
for pattern in patterns:
for match in re.findall(pattern, js, re.IGNORECASE):
if len(match) < 300:
found.add(match)
for item in sorted(found):
print(item)
+13
View File
@@ -0,0 +1,13 @@
import requests
ROUTER = "https://192.168.178.2"
r = requests.get(
ROUTER,
verify=False,
timeout=5
)
print(r.status_code)
print(r.url)
print(r.text[:1000])
+13
View File
@@ -0,0 +1,13 @@
import requests
import re
ROUTER = "https://192.168.178.2"
r = requests.get(ROUTER, verify=False, timeout=5)
# Find JavaScript files
scripts = re.findall(r'<script[^>]+src=([^ >]+)', r.text)
print("JavaScript files:")
for script in scripts:
print(script)
+73 -6
View File
@@ -2,14 +2,18 @@
from __future__ import annotations from __future__ import annotations
import argparse
import base64 import base64
import json import json
import os import os
import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import urlsplit from urllib.parse import urlsplit
import requests import requests
import yaml
from Crypto.Cipher import AES, PKCS1_v1_5 from Crypto.Cipher import AES, PKCS1_v1_5
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from Crypto.Util.Padding import pad, unpad from Crypto.Util.Padding import pad, unpad
@@ -145,13 +149,24 @@ class ZyxelRouter:
result = self.dal_get(self.hosts_oid) result = self.dal_get(self.hosts_oid)
if result.get("result") not in (None, "ZCFG_SUCCESS"): if result.get("result") not in (None, "ZCFG_SUCCESS"):
raise ZyxelError(f"lanhosts query failed: {result.get('result')}") raise ZyxelError(f"lanhosts query failed: {result.get('result')}")
objects = result.get("Object", [])
if isinstance(objects, dict): def host_records(value: Any):
objects = [objects] """Yield host dictionaries from flat and nested DAL response shapes."""
if isinstance(value, dict):
if any(
key in value
for key in ("PhysAddress", "physAddress", "MACAddr", "MacAddress")
):
yield value
return
for child in value.values():
yield from host_records(child)
elif isinstance(value, list):
for child in value:
yield from host_records(child)
hosts: list[LanHost] = [] hosts: list[LanHost] = []
for item in objects: for item in host_records(result.get("Object", [])):
if not isinstance(item, dict):
continue
raw_mac = next((item.get(key) for key in ("PhysAddress", "physAddress", "MACAddr", "MacAddress") if item.get(key)), None) raw_mac = next((item.get(key) for key in ("PhysAddress", "physAddress", "MACAddr", "MacAddress") if item.get(key)), None)
if not raw_mac: if not raw_mac:
continue continue
@@ -182,3 +197,55 @@ class ZyxelRouter:
self._session_key = None self._session_key = None
self._aes_key = None self._aes_key = None
self.session.close() self.session.close()
def _load_cli_config(path: Path) -> tuple[dict[str, Any], str]:
try:
with path.open(encoding="utf-8") as stream:
root = yaml.safe_load(stream)
arrival = root["arrival_detection"]
return arrival["zyxel"], str(arrival["device"]["mac"])
except (OSError, TypeError, KeyError, yaml.YAMLError) as error:
raise ZyxelError(f"could not load configuration from {path}: {error}") from error
def main() -> int:
parser = argparse.ArgumentParser(
description="Check whether a MAC address is connected to the configured Zyxel router."
)
parser.add_argument(
"--config",
type=Path,
default=Path(__file__).resolve().parents[1] / "service.yaml",
help="service configuration file (default: project service.yaml)",
)
parser.add_argument(
"--mac",
help="MAC address to check (default: arrival_detection.device.mac)",
)
args = parser.parse_args()
try:
config, configured_mac = _load_cli_config(args.config)
mac = normalize_mac(args.mac or configured_mac)
router = ZyxelRouter(
config["host"],
config["username"],
config["password"],
timeout=float(config.get("timeout", 10)),
verify_tls=bool(config.get("verify_tls", True)),
hosts_oid=str(config.get("hosts_oid", "lanhosts")),
)
try:
connected = router.is_connected(mac)
finally:
router.close()
print(f"{mac} {'connected' if connected else 'absent'}")
return 0
except (ValueError, ZyxelError) as error:
print(f"error: {error}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+23 -1
View File
@@ -3,7 +3,7 @@ from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from unittest.mock import patch from unittest.mock import patch
from lib._zyxel import normalize_mac from lib._zyxel import ZyxelRouter, normalize_mac
from lib._arrival_detection import ArrivalDetector, _notify from lib._arrival_detection import ArrivalDetector, _notify
@@ -35,6 +35,28 @@ class MacTests(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
normalize_mac("not-a-mac") normalize_mac("not-a-mac")
def test_parses_ex5601_nested_lanhosts_response(self):
router = ZyxelRouter("192.0.2.1", "user", "password")
router.dal_get = lambda _oid: {
"result": "ZCFG_SUCCESS",
"Object": [
{
"lanhosts": [
{
"PhysAddress": "2C:DB:07:50:2B:5C",
"Active": True,
"HostName": "workstation",
"IPAddress": "192.168.178.10",
}
]
}
],
}
hosts = router.get_lan_hosts()
self.assertEqual(len(hosts), 1)
self.assertEqual(hosts[0].mac, "2c:db:07:50:2b:5c")
self.assertTrue(router.is_connected("2c-db-07-50-2b-5c"))
class NotificationTests(unittest.TestCase): class NotificationTests(unittest.TestCase):
@patch("lib._arrival_detection.send_notification") @patch("lib._arrival_detection.send_notification")