verizon-router-client 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.3
2
+ Name: verizon-router-client
3
+ Version: 0.1.0
4
+ Summary: Python client for Verizon Fios router APIs (CR1000A web UI endpoints).
5
+ Author: Brishen Hawkins
6
+ Author-email: Brishen Hawkins <brishen.hawkins@gmail.com>
7
+ License: MIT
8
+ Requires-Dist: requests>=2.28.0
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+
12
+ # verizon-router-client
13
+
14
+ Python client for Verizon Fios router APIs (focused on the CR1000A web UI endpoints).
15
+
16
+ ## Features
17
+
18
+ - Login/token helpers for the web UI.
19
+ - Fetch status details (uptime, WAN IPs, DNS servers).
20
+ - Read/write local DNS entries.
21
+ - Read/add/remove port forwarding rules.
22
+ - Parse known device lists.
23
+
24
+ ## Install
25
+
26
+ Local editable install:
27
+
28
+ ```bash
29
+ pip install -e .
30
+ ```
31
+
32
+ Or with uv:
33
+
34
+ ```bash
35
+ uv pip install -e .
36
+ ```
37
+
38
+ ## Quickstart
39
+
40
+ ```python
41
+ from verizon_router_client.cr1000a import VerizonRouterClient
42
+
43
+ client = VerizonRouterClient(
44
+ base_url="https://192.168.1.1",
45
+ # The router uses a hostname-bound TLS cert; this adapter handles it.
46
+ tls_hostname="mynetworksettings.com",
47
+ )
48
+
49
+ client.login("admin", "your-password")
50
+
51
+ print(client.get_uptime_seconds())
52
+ print(client.get_wan_ipv4())
53
+ print(client.get_wan_ipv6())
54
+ ```
55
+
56
+ ## DNS entries
57
+
58
+ ```python
59
+ from verizon_router_client.cr1000a import VerizonRouterClient
60
+
61
+ client = VerizonRouterClient(
62
+ base_url="https://192.168.1.1",
63
+ # The router uses a hostname-bound TLS cert; this adapter handles it.
64
+ tls_hostname="mynetworksettings.com",
65
+ )
66
+ client.login("admin", "your-password")
67
+
68
+ print(client.get_dns_entries_v4())
69
+ slot = client.add_dns_ipv4("nas", "192.168.1.10")
70
+ client.clear_dns_ipv4_slot(slot)
71
+ ```
72
+
73
+ ## Port forwarding
74
+
75
+ ```python
76
+ from verizon_router_client.cr1000a import VerizonRouterClient
77
+
78
+ client = VerizonRouterClient(
79
+ base_url="https://192.168.1.1",
80
+ # The router uses a hostname-bound TLS cert; this adapter handles it.
81
+ tls_hostname="mynetworksettings.com",
82
+ )
83
+ client.login("admin", "your-password")
84
+
85
+ rule_id = client.add_port_forward(
86
+ name="ssh",
87
+ private_ip="192.168.1.20",
88
+ forward_port=22,
89
+ dest_port=22,
90
+ )
91
+
92
+ client.remove_port_forward(rule_id=rule_id)
93
+ ```
94
+
95
+ ## Known devices
96
+
97
+ ```python
98
+ from verizon_router_client.cr1000a import VerizonRouterClient
99
+
100
+ client = VerizonRouterClient(
101
+ base_url="https://192.168.1.1",
102
+ # The router uses a hostname-bound TLS cert; this adapter handles it.
103
+ tls_hostname="mynetworksettings.com",
104
+ )
105
+ devices = client.fetch_known_devices()
106
+ ```
107
+
108
+ If you are not already logged in, pass a `sysauth` cookie value:
109
+
110
+ ```python
111
+ from verizon_router_client.cr1000a import VerizonRouterClient
112
+
113
+ client = VerizonRouterClient(
114
+ base_url="https://192.168.1.1",
115
+ # The router uses a hostname-bound TLS cert; this adapter handles it.
116
+ tls_hostname="mynetworksettings.com",
117
+ )
118
+ devices = client.fetch_known_devices(sysauth_cookie_value="...")
119
+ ```
120
+
121
+ ## TLS notes
122
+
123
+ By default the client uses the bundled Verizon Fios root CA (`cert/Verizon Fios Root CA.pem`)
124
+ and sets the TLS SNI/Host header to `mynetworksettings.com` when the base URL is an IP.
125
+ If your router uses different TLS settings, override `verify_tls` or `tls_hostname`.
126
+
127
+ ## Development
128
+
129
+ - Python: 3.9+
130
+ - Runtime dependency: `requests`
131
+
132
+ ## Disclaimer
133
+
134
+ This project is not affiliated with Verizon. Use at your own risk.
@@ -0,0 +1,123 @@
1
+ # verizon-router-client
2
+
3
+ Python client for Verizon Fios router APIs (focused on the CR1000A web UI endpoints).
4
+
5
+ ## Features
6
+
7
+ - Login/token helpers for the web UI.
8
+ - Fetch status details (uptime, WAN IPs, DNS servers).
9
+ - Read/write local DNS entries.
10
+ - Read/add/remove port forwarding rules.
11
+ - Parse known device lists.
12
+
13
+ ## Install
14
+
15
+ Local editable install:
16
+
17
+ ```bash
18
+ pip install -e .
19
+ ```
20
+
21
+ Or with uv:
22
+
23
+ ```bash
24
+ uv pip install -e .
25
+ ```
26
+
27
+ ## Quickstart
28
+
29
+ ```python
30
+ from verizon_router_client.cr1000a import VerizonRouterClient
31
+
32
+ client = VerizonRouterClient(
33
+ base_url="https://192.168.1.1",
34
+ # The router uses a hostname-bound TLS cert; this adapter handles it.
35
+ tls_hostname="mynetworksettings.com",
36
+ )
37
+
38
+ client.login("admin", "your-password")
39
+
40
+ print(client.get_uptime_seconds())
41
+ print(client.get_wan_ipv4())
42
+ print(client.get_wan_ipv6())
43
+ ```
44
+
45
+ ## DNS entries
46
+
47
+ ```python
48
+ from verizon_router_client.cr1000a import VerizonRouterClient
49
+
50
+ client = VerizonRouterClient(
51
+ base_url="https://192.168.1.1",
52
+ # The router uses a hostname-bound TLS cert; this adapter handles it.
53
+ tls_hostname="mynetworksettings.com",
54
+ )
55
+ client.login("admin", "your-password")
56
+
57
+ print(client.get_dns_entries_v4())
58
+ slot = client.add_dns_ipv4("nas", "192.168.1.10")
59
+ client.clear_dns_ipv4_slot(slot)
60
+ ```
61
+
62
+ ## Port forwarding
63
+
64
+ ```python
65
+ from verizon_router_client.cr1000a import VerizonRouterClient
66
+
67
+ client = VerizonRouterClient(
68
+ base_url="https://192.168.1.1",
69
+ # The router uses a hostname-bound TLS cert; this adapter handles it.
70
+ tls_hostname="mynetworksettings.com",
71
+ )
72
+ client.login("admin", "your-password")
73
+
74
+ rule_id = client.add_port_forward(
75
+ name="ssh",
76
+ private_ip="192.168.1.20",
77
+ forward_port=22,
78
+ dest_port=22,
79
+ )
80
+
81
+ client.remove_port_forward(rule_id=rule_id)
82
+ ```
83
+
84
+ ## Known devices
85
+
86
+ ```python
87
+ from verizon_router_client.cr1000a import VerizonRouterClient
88
+
89
+ client = VerizonRouterClient(
90
+ base_url="https://192.168.1.1",
91
+ # The router uses a hostname-bound TLS cert; this adapter handles it.
92
+ tls_hostname="mynetworksettings.com",
93
+ )
94
+ devices = client.fetch_known_devices()
95
+ ```
96
+
97
+ If you are not already logged in, pass a `sysauth` cookie value:
98
+
99
+ ```python
100
+ from verizon_router_client.cr1000a import VerizonRouterClient
101
+
102
+ client = VerizonRouterClient(
103
+ base_url="https://192.168.1.1",
104
+ # The router uses a hostname-bound TLS cert; this adapter handles it.
105
+ tls_hostname="mynetworksettings.com",
106
+ )
107
+ devices = client.fetch_known_devices(sysauth_cookie_value="...")
108
+ ```
109
+
110
+ ## TLS notes
111
+
112
+ By default the client uses the bundled Verizon Fios root CA (`cert/Verizon Fios Root CA.pem`)
113
+ and sets the TLS SNI/Host header to `mynetworksettings.com` when the base URL is an IP.
114
+ If your router uses different TLS settings, override `verify_tls` or `tls_hostname`.
115
+
116
+ ## Development
117
+
118
+ - Python: 3.9+
119
+ - Runtime dependency: `requests`
120
+
121
+ ## Disclaimer
122
+
123
+ This project is not affiliated with Verizon. Use at your own risk.
@@ -0,0 +1,17 @@
1
+ [project]
2
+ name = "verizon-router-client"
3
+ version = "0.1.0"
4
+ description = "Python client for Verizon Fios router APIs (CR1000A web UI endpoints)."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Brishen Hawkins", email = "brishen.hawkins@gmail.com" }
8
+ ]
9
+ license = { text = "MIT" }
10
+ requires-python = ">=3.9"
11
+ dependencies = [
12
+ "requests>=2.28.0",
13
+ ]
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.8.15,<0.9.0"]
17
+ build-backend = "uv_build"
@@ -0,0 +1,2 @@
1
+ def hello() -> str:
2
+ return "Hello from verizon-router-client!"
@@ -0,0 +1,34 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ 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
34
+ -----END CERTIFICATE-----
@@ -0,0 +1,773 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import hashlib
5
+ import ipaddress
6
+ import json
7
+ import re
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from urllib.parse import urlparse
12
+
13
+ import requests
14
+ import urllib3
15
+
16
+ _DEFAULT_CA_CERT = (
17
+ Path(__file__).resolve().parent / "cert" / "Verizon Fios Root CA.pem"
18
+ )
19
+
20
+
21
+ def _is_ip_host(host: str) -> bool:
22
+ try:
23
+ ipaddress.ip_address(host)
24
+ except ValueError:
25
+ return False
26
+ return True
27
+
28
+
29
+ class HostHeaderSSLAdapter(requests.adapters.HTTPAdapter):
30
+ def __init__(self, tls_hostname: str, **kwargs: Any) -> None:
31
+ self._tls_hostname = tls_hostname
32
+ super().__init__(**kwargs)
33
+
34
+ def init_poolmanager(
35
+ self, connections: int, maxsize: int, block: bool = False, **pool_kwargs: Any
36
+ ) -> None:
37
+ pool_kwargs["assert_hostname"] = self._tls_hostname
38
+ pool_kwargs["server_hostname"] = self._tls_hostname
39
+ self.poolmanager = urllib3.PoolManager(
40
+ num_pools=connections, maxsize=maxsize, block=block, **pool_kwargs
41
+ )
42
+
43
+ def proxy_manager_for(self, proxy: str, **proxy_kwargs: Any) -> urllib3.ProxyManager:
44
+ proxy_kwargs["assert_hostname"] = self._tls_hostname
45
+ proxy_kwargs["server_hostname"] = self._tls_hostname
46
+ return super().proxy_manager_for(proxy, **proxy_kwargs)
47
+
48
+ def _js_8bit_bytes(s: str) -> bytes:
49
+ # Matches the JS code path that effectively uses charCodeAt(...) & 0xFF.
50
+ return bytes((ord(ch) & 0xFF) for ch in s)
51
+
52
+
53
+ def arc_md5(s: str) -> str:
54
+ """
55
+ JS ArcMD5(s):
56
+ md5_hex = md5(js_8bit_bytes(s)).hexdigest() # lowercase hex
57
+ return sha512(md5_hex as ASCII).hexdigest() # lowercase hex
58
+ """
59
+ md5_hex = hashlib.md5(_js_8bit_bytes(s)).hexdigest()
60
+ return hashlib.sha512(md5_hex.encode("ascii")).hexdigest()
61
+
62
+
63
+ def luci_username(username: str) -> str:
64
+ return arc_md5(username)
65
+
66
+
67
+ def luci_password(password: str, luci_token: str) -> str:
68
+ if not luci_token:
69
+ return arc_md5(password)
70
+ material = (luci_token + arc_md5(password)).encode("ascii")
71
+ return hashlib.sha512(material).hexdigest()
72
+
73
+
74
+ _ADD_CFG_RE = re.compile(
75
+ r'addCfg\("(?P<key>[^"]+)",\s*"(?P<enc>[^"]*)",\s*"(?P<val>[^"]*)"\);'
76
+ )
77
+ _KEY_WITH_INDEX_RE = re.compile(r"^(?P<prefix>.*?)(?P<idx>\d+)$")
78
+
79
+ _ADD_ROD_RE = re.compile(
80
+ r'addROD\("(?P<key>[^"]+)",\s*(?P<val>.*?)\);\s*', re.DOTALL
81
+ )
82
+
83
+
84
+ def _strip_wrapping_quotes(s: str) -> str:
85
+ out = s.strip()
86
+ # Some firmwares double-wrap values, so strip at most twice.
87
+ for _ in range(2):
88
+ if len(out) >= 2 and ((out[0] == out[-1] == '"') or (out[0] == out[-1] == "'")):
89
+ out = out[1:-1].strip()
90
+ else:
91
+ break
92
+ return out
93
+
94
+
95
+ def _parse_js_literal(s: str) -> Any:
96
+ v = s.strip()
97
+ v = re.sub(r"\bnull\b", "None", v)
98
+ v = re.sub(r"\btrue\b", "True", v)
99
+ v = re.sub(r"\bfalse\b", "False", v)
100
+ v = v.replace(r"\/", "/")
101
+
102
+ try:
103
+ parsed = ast.literal_eval(v)
104
+ except Exception:
105
+ # Raw fallback; still normalize obvious wrapping quotes.
106
+ return _strip_wrapping_quotes(v)
107
+
108
+ if isinstance(parsed, str):
109
+ return _strip_wrapping_quotes(parsed)
110
+ return parsed
111
+
112
+
113
+ @dataclass(frozen=True, slots=True)
114
+ class CfgEntry:
115
+ key: str
116
+ enc_name: str # obfuscated field name used in apply_abstract.cgi
117
+ val: str # plaintext value shown in the UI
118
+
119
+
120
+ @dataclass(slots=True)
121
+ class VerizonRouterClient:
122
+ base_url: str = "https://192.168.1.1"
123
+ verify_tls: bool | str = str(_DEFAULT_CA_CERT)
124
+ tls_hostname: str | None = None
125
+ timeout_s: float = 10.0
126
+ session: requests.Session | None = None
127
+
128
+ def __post_init__(self) -> None:
129
+ if self.session is None:
130
+ self.session = requests.Session()
131
+ if self.tls_hostname is None:
132
+ host = urlparse(self.base_url).hostname
133
+ if host and _is_ip_host(host):
134
+ self.tls_hostname = "mynetworksettings.com"
135
+ if self.tls_hostname:
136
+ self.session.mount("https://", HostHeaderSSLAdapter(self.tls_hostname))
137
+
138
+ def _request_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
139
+ headers = {"Referer": f"{self.base_url.rstrip('/')}/"}
140
+ if self.tls_hostname:
141
+ headers["Host"] = self.tls_hostname
142
+ if extra:
143
+ headers.update(extra)
144
+ return headers
145
+
146
+ def get_login_token(self) -> str:
147
+ data = self.login_status()
148
+ token = data.get("loginToken")
149
+ if not isinstance(token, str) or len(token) != 32:
150
+ raise RuntimeError(f"Missing/invalid loginToken in loginStatus: {data}")
151
+ return token.lower()
152
+
153
+ def login(
154
+ self,
155
+ username: str,
156
+ password: str,
157
+ *,
158
+ view: str = "Mobile",
159
+ keep_login: bool = False,
160
+ ) -> requests.Response:
161
+ login_token = self.get_login_token()
162
+
163
+ payload = {
164
+ "luci_username": luci_username(username),
165
+ "luci_password": luci_password(password, login_token),
166
+ "luci_view": view,
167
+ "luci_token": login_token,
168
+ "luci_keep_login": "1" if keep_login else "0",
169
+ }
170
+
171
+ url = f"{self.base_url.rstrip('/')}/login.cgi"
172
+ r = self.session.post(
173
+ url,
174
+ data=payload, # form-encoded
175
+ timeout=self.timeout_s,
176
+ verify=self.verify_tls,
177
+ headers=self._request_headers(
178
+ {"Content-Type": "application/x-www-form-urlencoded"}
179
+ ),
180
+ )
181
+ return r
182
+
183
+ def get_dns_cfg(self) -> dict[str, str]:
184
+ """
185
+ Fetch /cgi/cgi_dns_server.js and return plaintext values keyed by addCfg key.
186
+ """
187
+ text = self._get("/cgi/cgi_dns_server.js").text
188
+ cfg_entries = self._parse_addcfg(text)
189
+ cfg = {key: entry.val for key, entry in cfg_entries.items()}
190
+ if not cfg:
191
+ # Often indicates you were served a login page / redirect JS instead of cfg JS.
192
+ raise RuntimeError(
193
+ "No addCfg() entries found in /cgi/cgi_dns_server.js response."
194
+ )
195
+ return cfg
196
+
197
+ @staticmethod
198
+ def _indexed(prefix: str, cfg: dict[str, str]) -> dict[int, str]:
199
+ out: dict[int, str] = {}
200
+ for k, v in cfg.items():
201
+ m = _KEY_WITH_INDEX_RE.match(k)
202
+ if not m:
203
+ continue
204
+ if m.group("prefix") != prefix:
205
+ continue
206
+ out[int(m.group("idx"))] = v
207
+ return out
208
+
209
+ def get_dns_entries_v4(self) -> list[dict[str, Any]]:
210
+ """
211
+ Returns rows like:
212
+ {"idx": 0, "name": "example-host", "ip": "192.168.1.2"}
213
+ Only returns indices where either name or ip is non-empty.
214
+ """
215
+ cfg = self.get_dns_cfg()
216
+ ips = self._indexed("dns_ip", cfg)
217
+ names = self._indexed("dns_name", cfg)
218
+
219
+ rows: list[dict[str, Any]] = []
220
+ for idx in sorted(set(ips) | set(names)):
221
+ ip = ips.get(idx, "") or ""
222
+ name = names.get(idx, "") or ""
223
+ if ip or name:
224
+ rows.append({"idx": idx, "name": name, "ip": ip})
225
+ return rows
226
+
227
+ def get_dns_entries_v6(self) -> list[dict[str, Any]]:
228
+ cfg = self.get_dns_cfg()
229
+ ips = self._indexed("dns_ipv6_ip", cfg)
230
+ names = self._indexed("dns_ipv6_name", cfg)
231
+
232
+ rows: list[dict[str, Any]] = []
233
+ for idx in sorted(set(ips) | set(names)):
234
+ ip = ips.get(idx, "") or ""
235
+ name = names.get(idx, "") or ""
236
+ if ip or name:
237
+ rows.append({"idx": idx, "name": name, "ip": ip})
238
+ return rows
239
+
240
+ def _url(self, path: str) -> str:
241
+ return f"{self.base_url.rstrip('/')}/{path.lstrip('/')}"
242
+
243
+ def _get(self, path: str) -> requests.Response:
244
+ r = self.session.get(
245
+ self._url(path),
246
+ timeout=self.timeout_s,
247
+ verify=self.verify_tls,
248
+ headers=self._request_headers(),
249
+ )
250
+ r.raise_for_status()
251
+ return r
252
+
253
+ @staticmethod
254
+ def _parse_addcfg(text: str) -> dict[str, CfgEntry]:
255
+ cfg: dict[str, CfgEntry] = {}
256
+ for m in _ADD_CFG_RE.finditer(text):
257
+ key = m.group("key")
258
+ cfg[key] = CfgEntry(key=key, enc_name=m.group("enc"), val=m.group("val"))
259
+ return cfg
260
+
261
+ @staticmethod
262
+ def _parse_addrod(text: str) -> dict[str, Any]:
263
+ rod: dict[str, Any] = {}
264
+ for m in _ADD_ROD_RE.finditer(text):
265
+ key = m.group("key")
266
+ rod[key] = _parse_js_literal(m.group("val"))
267
+ return rod
268
+
269
+ def get_uptime_seconds(self) -> int:
270
+ rod, _cfg = self.fetch_status()
271
+ uptime = rod.get("uptime")
272
+ if isinstance(uptime, str) and uptime.isdigit():
273
+ return int(uptime)
274
+ if isinstance(uptime, int):
275
+ return uptime
276
+ raise RuntimeError(f"Unexpected uptime value: {uptime!r}")
277
+
278
+ def get_wan_ipv4(self) -> str | None:
279
+ rod, _cfg = self.fetch_status()
280
+ v = rod.get("get_wan4_ip")
281
+ return v if isinstance(v, str) and v else None
282
+
283
+ def get_wan_ipv6(self) -> str | None:
284
+ rod, _cfg = self.fetch_status()
285
+ v = rod.get("cgi_wan_ip6_addr")
286
+ return v if isinstance(v, str) and v else None
287
+
288
+ def get_wan_dns_servers(self) -> list[str]:
289
+ _rod, cfg = self.fetch_status()
290
+ v = cfg.get("wan_ip4_dns")
291
+ if not v or not v.val.strip():
292
+ return []
293
+ return [x for x in v.val.split() if x]
294
+
295
+ def fetch_status(self) -> tuple[dict[str, Any], dict[str, CfgEntry]]:
296
+ """
297
+ GET /cgi/cgi_status.js and parse:
298
+ - addROD(...) into a dict[str, Any]
299
+ - addCfg(...) into a dict[str, CfgEntry]
300
+ """
301
+ text = self._get("/cgi/cgi_status.js").text
302
+ rod = self._parse_addrod(text)
303
+ cfg = self._parse_addcfg(text)
304
+ if not rod and not cfg:
305
+ raise RuntimeError("No addROD/addCfg entries found in /cgi/cgi_status.js")
306
+ return rod, cfg
307
+
308
+ def fetch_port_forwarding(self) -> dict[str, Any]:
309
+ """
310
+ GET /cgi/cgi_firewall_port_forward.js and parse addROD(...) payloads.
311
+ """
312
+ text = self._get("/cgi/cgi_firewall_port_forward.js").text
313
+ rod = self._parse_addrod(text)
314
+ if not rod:
315
+ raise RuntimeError(
316
+ "No addROD() entries found in /cgi/cgi_firewall_port_forward.js"
317
+ )
318
+ return rod
319
+
320
+ def get_port_forwarding_settings(self) -> dict[str, Any]:
321
+ """
322
+ Returns a normalized view of the port forwarding payload, with null entries removed.
323
+ """
324
+ rod = self.fetch_port_forwarding()
325
+
326
+ portforwardings = rod.get("portforwardings")
327
+ if not isinstance(portforwardings, dict):
328
+ raise RuntimeError(
329
+ f"Unexpected portforwardings payload: {type(portforwardings)}"
330
+ )
331
+ entries = portforwardings.get("portforwardings")
332
+ if not isinstance(entries, list):
333
+ raise RuntimeError(
334
+ f"Unexpected portforwardings list: {type(entries)}"
335
+ )
336
+
337
+ upnp = rod.get("upnpportforwardings")
338
+ if upnp is None:
339
+ upnp_entries: list[Any] = []
340
+ elif isinstance(upnp, list):
341
+ upnp_entries = [entry for entry in upnp if entry is not None]
342
+ else:
343
+ raise RuntimeError(
344
+ f"Unexpected upnpportforwardings payload: {type(upnp)}"
345
+ )
346
+
347
+ readonly = rod.get("readonly_portforwardings")
348
+ if readonly is None:
349
+ readonly_entries: list[Any] = []
350
+ elif isinstance(readonly, list):
351
+ readonly_entries = [entry for entry in readonly if entry is not None]
352
+ else:
353
+ raise RuntimeError(
354
+ f"Unexpected readonly_portforwardings payload: {type(readonly)}"
355
+ )
356
+
357
+ return {
358
+ "portforwardings": entries,
359
+ "upnpportforwardings": upnp_entries,
360
+ "readonly_portforwardings": readonly_entries,
361
+ "portrules": rod.get("portrules"),
362
+ "schedulerules": rod.get("schedulerules"),
363
+ "reservePort": rod.get("reservePort"),
364
+ }
365
+
366
+ def _post_form(self, path: str, data: dict[str, str]) -> requests.Response:
367
+ r = self.session.post(
368
+ self._url(path),
369
+ data=data,
370
+ timeout=self.timeout_s,
371
+ verify=self.verify_tls,
372
+ headers=self._request_headers(
373
+ {"Content-Type": "application/x-www-form-urlencoded"}
374
+ ),
375
+ )
376
+ r.raise_for_status()
377
+ return r
378
+
379
+ def _post_db(self, payload: dict[str, Any], *, token: str | None = None) -> Any:
380
+ if token is None:
381
+ token = self.get_apply_token()
382
+ data = {"data": json.dumps(payload), "token": token}
383
+ r = self._post_form("/db.cgi", data)
384
+ try:
385
+ return r.json()
386
+ except ValueError:
387
+ return r.text
388
+
389
+ # ---- tokens / auth ----
390
+ def login_status(self) -> dict[str, Any]:
391
+ data = self._get("/loginStatus.cgi").json()
392
+ if not isinstance(data, dict):
393
+ raise RuntimeError(f"Unexpected loginStatus.cgi JSON: {type(data)}")
394
+ return data
395
+
396
+ def get_apply_token(self) -> str:
397
+ """
398
+ Token used in apply_abstract.cgi. In many firmwares it becomes non-empty after login.
399
+ """
400
+ tok = self.login_status().get("token")
401
+ if isinstance(tok, str) and tok:
402
+ return tok
403
+ raise RuntimeError(
404
+ "No non-empty apply token in loginStatus.cgi. "
405
+ "After login, check loginStatus.cgi again; if still empty, the token is likely loaded from another endpoint/page."
406
+ )
407
+
408
+ def add_port_forward(
409
+ self,
410
+ *,
411
+ name: str,
412
+ private_ip: str,
413
+ forward_port: int | str,
414
+ dest_port: int | str,
415
+ enable: bool = True,
416
+ schedule_rule_id: int | str = 0,
417
+ port_type: int = 8,
418
+ source_type: int = 0,
419
+ source_port: str = "",
420
+ dest_type: int = 1,
421
+ token: str | None = None,
422
+ ) -> int:
423
+ """
424
+ Create a port forwarding rule via /db.cgi and return the new rule id.
425
+ """
426
+ payload = {
427
+ "type": "edit",
428
+ "to": "forwardrule",
429
+ "body": [
430
+ {
431
+ "type": "create",
432
+ "enable": "1" if enable else "0",
433
+ "name": name,
434
+ "privateIP": private_ip,
435
+ "forward_port": str(forward_port),
436
+ "schedule_rule_id": str(schedule_rule_id),
437
+ "ports": [
438
+ {
439
+ "type": port_type,
440
+ "source_type": source_type,
441
+ "source_port": source_port,
442
+ "dest_type": dest_type,
443
+ "dest_port": str(dest_port),
444
+ }
445
+ ],
446
+ }
447
+ ],
448
+ }
449
+ response = self._post_db(payload, token=token)
450
+ rule_id = self._extract_rule_id_from_response(response)
451
+ if rule_id is not None:
452
+ return rule_id
453
+ rule_id = self._lookup_port_forward_id(
454
+ name=name,
455
+ private_ip=private_ip,
456
+ forward_port=forward_port,
457
+ dest_port=dest_port,
458
+ )
459
+ if rule_id is None:
460
+ raise RuntimeError(
461
+ f"Port forward created but rule id not found. Response: {response!r}"
462
+ )
463
+ return rule_id
464
+
465
+ def remove_port_forward(
466
+ self,
467
+ *,
468
+ rule_id: int | str,
469
+ token: str | None = None,
470
+ ) -> Any:
471
+ """
472
+ Remove a port forwarding rule via /db.cgi.
473
+ """
474
+ payload = {
475
+ "type": "edit",
476
+ "to": "forwardrule",
477
+ "body": [{"type": "delete", "id": str(rule_id)}],
478
+ }
479
+ return self._post_db(payload, token=token)
480
+
481
+ @staticmethod
482
+ def _extract_rule_id_from_response(response: Any) -> int | None:
483
+ if isinstance(response, dict):
484
+ for key in ("id", "rule_id", "forward_rule_id"):
485
+ value = response.get(key)
486
+ if isinstance(value, int):
487
+ return value
488
+ if isinstance(value, str) and value.isdigit():
489
+ return int(value)
490
+ body = response.get("body")
491
+ if isinstance(body, list) and body:
492
+ first = body[0]
493
+ if isinstance(first, dict):
494
+ value = first.get("id")
495
+ if isinstance(value, int):
496
+ return value
497
+ if isinstance(value, str) and value.isdigit():
498
+ return int(value)
499
+ return None
500
+
501
+ def _lookup_port_forward_id(
502
+ self,
503
+ *,
504
+ name: str,
505
+ private_ip: str,
506
+ forward_port: int | str,
507
+ dest_port: int | str,
508
+ ) -> int | None:
509
+ settings = self.get_port_forwarding_settings()
510
+ entries = settings.get("portforwardings")
511
+ if not isinstance(entries, list):
512
+ return None
513
+
514
+ port_rule_ports: dict[int, set[str]] = {}
515
+ portrules = settings.get("portrules")
516
+ if isinstance(portrules, dict):
517
+ rules = portrules.get("portrules")
518
+ if isinstance(rules, list):
519
+ for rule in rules:
520
+ if not isinstance(rule, dict):
521
+ continue
522
+ rule_id = rule.get("id")
523
+ ports = rule.get("ports")
524
+ if not isinstance(rule_id, int) or not isinstance(ports, list):
525
+ continue
526
+ dest_ports = {
527
+ str(port.get("dest_port"))
528
+ for port in ports
529
+ if isinstance(port, dict) and "dest_port" in port
530
+ }
531
+ port_rule_ports[rule_id] = dest_ports
532
+
533
+ matches: list[int] = []
534
+ for entry in entries:
535
+ if not isinstance(entry, dict):
536
+ continue
537
+ if entry.get("name") != name:
538
+ continue
539
+ if entry.get("privateIP") != private_ip:
540
+ continue
541
+ if str(entry.get("forward_port")) != str(forward_port):
542
+ continue
543
+ port_rule_id = entry.get("port_rule_id")
544
+ if isinstance(port_rule_id, int):
545
+ dest_ports = port_rule_ports.get(port_rule_id)
546
+ if dest_ports and str(dest_port) not in dest_ports:
547
+ continue
548
+ entry_id = entry.get("id")
549
+ if isinstance(entry_id, int):
550
+ matches.append(entry_id)
551
+
552
+ if not matches:
553
+ return None
554
+ return max(matches)
555
+
556
+ # ---- cfg parsing ----
557
+ def fetch_dns_cfg(self) -> dict[str, CfgEntry]:
558
+ """
559
+ GET /cgi/cgi_dns_server.js and parse addCfg() into a mapping:
560
+ logical key -> (obfuscated field name, plaintext value)
561
+ """
562
+ text = self._get("/cgi/cgi_dns_server.js").text
563
+ cfg = self._parse_addcfg(text)
564
+ if not cfg:
565
+ raise RuntimeError("No addCfg() entries found in /cgi/cgi_dns_server.js")
566
+ return cfg
567
+
568
+ # ---- read helpers ----
569
+ @staticmethod
570
+ def list_dns_ipv4(
571
+ cfg: dict[str, CfgEntry], *, max_n: int = 64
572
+ ) -> list[tuple[int, str, str]]:
573
+ """
574
+ Returns [(idx, hostname, ip), ...] for non-empty hostnames.
575
+ """
576
+ out: list[tuple[int, str, str]] = []
577
+ for i in range(max_n):
578
+ name = cfg.get(f"dns_name{i}")
579
+ ip = cfg.get(f"dns_ip{i}")
580
+ if not name or not ip:
581
+ continue
582
+ if name.val.strip():
583
+ out.append((i, name.val, ip.val))
584
+ return out
585
+
586
+ # ---- write helpers ----
587
+ def _apply_dnsmasq_reload(self, field_updates: dict[str, str]) -> None:
588
+ token = self.get_apply_token()
589
+ data = {"token": token, "action": "dnsmasq_reload"}
590
+ data.update(field_updates)
591
+ self._post_form("/apply_abstract.cgi", data)
592
+
593
+ def add_dns_ipv4(self, hostname: str, ip: str) -> int:
594
+ cfg = self.fetch_dns_cfg()
595
+
596
+ slot = None
597
+ for i in range(64):
598
+ name = cfg.get(f"dns_name{i}")
599
+ if name and not name.val.strip():
600
+ slot = i
601
+ break
602
+ if slot is None:
603
+ raise RuntimeError("No empty dns_nameN slots available (0..63).")
604
+
605
+ name_entry = cfg.get(f"dns_name{slot}")
606
+ ip_entry = cfg.get(f"dns_ip{slot}")
607
+ if not name_entry or not ip_entry:
608
+ raise RuntimeError(f"Missing cfg entries for slot {slot}.")
609
+
610
+ self._apply_dnsmasq_reload(
611
+ {
612
+ ip_entry.enc_name: ip,
613
+ name_entry.enc_name: hostname,
614
+ }
615
+ )
616
+ return slot
617
+
618
+ # ---- remove / clear helpers ----
619
+ def clear_dns_ipv4_slot(self, slot: int) -> None:
620
+ if not (0 <= slot < 64):
621
+ raise ValueError("slot must be in [0, 63].")
622
+ cfg = self.fetch_dns_cfg()
623
+
624
+ name_entry = cfg.get(f"dns_name{slot}")
625
+ ip_entry = cfg.get(f"dns_ip{slot}")
626
+ if not name_entry or not ip_entry:
627
+ raise RuntimeError(f"Missing cfg entries for slot {slot}.")
628
+
629
+ # Clearing both fields matches the UI’s “edit then apply” model.
630
+ self._apply_dnsmasq_reload({ip_entry.enc_name: "", name_entry.enc_name: ""})
631
+
632
+ def clear_dns_ipv6_slot(self, slot: int) -> None:
633
+ if not (0 <= slot < 64):
634
+ raise ValueError("slot must be in [0, 63].")
635
+ cfg = self.fetch_dns_cfg()
636
+
637
+ name_entry = cfg.get(f"dns_ipv6_name{slot}")
638
+ ip_entry = cfg.get(f"dns_ipv6_ip{slot}")
639
+ if not name_entry or not ip_entry:
640
+ raise RuntimeError(f"Missing cfg entries for slot {slot}.")
641
+
642
+ self._apply_dnsmasq_reload({ip_entry.enc_name: "", name_entry.enc_name: ""})
643
+
644
+ def remove_dns_ipv4_by_hostname(
645
+ self, hostname: str, *, remove_all: bool = False
646
+ ) -> list[int]:
647
+ cfg = self.fetch_dns_cfg()
648
+ matches = [
649
+ idx for idx, host, _ip in self.list_dns_ipv4(cfg) if host == hostname
650
+ ]
651
+ if not matches:
652
+ return []
653
+ removed: list[int] = []
654
+ for idx in matches if remove_all else matches[:1]:
655
+ self.clear_dns_ipv4_slot(idx)
656
+ removed.append(idx)
657
+ return removed
658
+
659
+ def remove_dns_ipv4_by_ip(self, ip: str, *, remove_all: bool = False) -> list[int]:
660
+ cfg = self.fetch_dns_cfg()
661
+ matches = [idx for idx, _host, addr in self.list_dns_ipv4(cfg) if addr == ip]
662
+ if not matches:
663
+ return []
664
+ removed: list[int] = []
665
+ for idx in matches if remove_all else matches[:1]:
666
+ self.clear_dns_ipv4_slot(idx)
667
+ removed.append(idx)
668
+ return removed
669
+
670
+ def remove_dns_ipv6_by_hostname(
671
+ self, hostname: str, *, remove_all: bool = False
672
+ ) -> list[int]:
673
+ cfg = self.fetch_dns_cfg()
674
+ matches = [
675
+ idx for idx, host, _ip in self.list_dns_ipv6(cfg) if host == hostname
676
+ ]
677
+ if not matches:
678
+ return []
679
+ removed: list[int] = []
680
+ for idx in matches if remove_all else matches[:1]:
681
+ self.clear_dns_ipv6_slot(idx)
682
+ removed.append(idx)
683
+ return removed
684
+
685
+ def remove_dns_ipv6_by_ip(self, ip: str, *, remove_all: bool = False) -> list[int]:
686
+ cfg = self.fetch_dns_cfg()
687
+ matches = [idx for idx, _host, addr in self.list_dns_ipv6(cfg) if addr == ip]
688
+ if not matches:
689
+ return []
690
+ removed: list[int] = []
691
+ for idx in matches if remove_all else matches[:1]:
692
+ self.clear_dns_ipv6_slot(idx)
693
+ removed.append(idx)
694
+ return removed
695
+
696
+ @staticmethod
697
+ def _extract_js_object_argument(src: str, call_prefix: str) -> str:
698
+ """
699
+ Extract the {...} argument from a JS call like:
700
+ addROD("known_device_list", {...});
701
+ using brace matching (safe against nested objects/arrays).
702
+ """
703
+ i = src.find(call_prefix)
704
+ if i < 0:
705
+ raise ValueError(f"Call prefix not found: {call_prefix!r}")
706
+
707
+ j = src.find("{", i)
708
+ if j < 0:
709
+ raise ValueError("No '{' found after call prefix.")
710
+
711
+ depth = 0
712
+ in_str = False
713
+ esc = False
714
+
715
+ for k in range(j, len(src)):
716
+ ch = src[k]
717
+
718
+ if in_str:
719
+ if esc:
720
+ esc = False
721
+ elif ch == "\\":
722
+ esc = True
723
+ elif ch == '"':
724
+ in_str = False
725
+ continue
726
+
727
+ if ch == '"':
728
+ in_str = True
729
+ continue
730
+
731
+ if ch == "{":
732
+ depth += 1
733
+ elif ch == "}":
734
+ depth -= 1
735
+ if depth == 0:
736
+ return src[j : k + 1]
737
+
738
+ raise ValueError("Unbalanced braces while extracting object literal.")
739
+
740
+ @staticmethod
741
+ def parse_known_devices(js_text: str) -> list[dict[str, Any]]:
742
+ obj_text = VerizonRouterClient._extract_js_object_argument(
743
+ js_text,
744
+ 'addROD("known_device_list",',
745
+ )
746
+ payload = json.loads(obj_text)
747
+ devices = payload.get("known_devices", [])
748
+ if isinstance(devices, list):
749
+ return devices
750
+ raise RuntimeError(f"Unexpected known_devices type: {type(devices)}")
751
+
752
+ def fetch_known_devices(
753
+ self, *, sysauth_cookie_value: str | None = None
754
+ ) -> list[dict[str, Any]]:
755
+ """
756
+ Fetch /cgi/cgi_owl.js and parse known device list.
757
+
758
+ If sysauth_cookie_value is not provided, this relies on the current session
759
+ already having a valid 'sysauth' cookie.
760
+ """
761
+ url = self._url("/cgi/cgi_owl.js")
762
+ headers = self._request_headers({"Accept": "application/json, text/plain, */*"})
763
+ cookies = {"sysauth": sysauth_cookie_value} if sysauth_cookie_value else None
764
+
765
+ r = self.session.get(
766
+ url,
767
+ headers=headers,
768
+ cookies=cookies,
769
+ timeout=self.timeout_s,
770
+ verify=self.verify_tls,
771
+ )
772
+ r.raise_for_status()
773
+ return self.parse_known_devices(r.text)