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.
- verizon_router_client-0.1.0/PKG-INFO +134 -0
- verizon_router_client-0.1.0/README.md +123 -0
- verizon_router_client-0.1.0/pyproject.toml +17 -0
- verizon_router_client-0.1.0/src/verizon_router_client/__init__.py +2 -0
- verizon_router_client-0.1.0/src/verizon_router_client/cert/Verizon Fios Root CA.pem +34 -0
- verizon_router_client-0.1.0/src/verizon_router_client/cr1000a.py +773 -0
- verizon_router_client-0.1.0/src/verizon_router_client/py.typed +0 -0
|
@@ -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,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)
|
|
File without changes
|