router-cli 0.1.0__tar.gz → 0.3.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.
- router_cli-0.1.0/README.md → router_cli-0.3.0/PKG-INFO +57 -5
- router_cli-0.1.0/PKG-INFO → router_cli-0.3.0/README.md +32 -15
- router_cli-0.3.0/pyproject.toml +37 -0
- {router_cli-0.1.0 → router_cli-0.3.0}/src/router_cli/__init__.py +1 -1
- router_cli-0.3.0/src/router_cli/client.py +320 -0
- router_cli-0.3.0/src/router_cli/commands.py +227 -0
- router_cli-0.3.0/src/router_cli/config.py +196 -0
- router_cli-0.3.0/src/router_cli/display.py +191 -0
- router_cli-0.1.0/src/router_cli/main.py → router_cli-0.3.0/src/router_cli/formatters.py +19 -355
- router_cli-0.3.0/src/router_cli/main.py +118 -0
- router_cli-0.3.0/src/router_cli/models.py +165 -0
- router_cli-0.3.0/src/router_cli/parser.py +521 -0
- router_cli-0.1.0/pyproject.toml +0 -18
- router_cli-0.1.0/src/router_cli/client.py +0 -883
- router_cli-0.1.0/src/router_cli/config.py +0 -66
- {router_cli-0.1.0 → router_cli-0.3.0}/src/router_cli/py.typed +0 -0
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: router-cli
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: CLI tool to manage D-Link DSL-2750U router
|
|
5
|
+
Keywords: router,cli,dlink,dsl-2750u,network,admin
|
|
6
|
+
Author: d3vr
|
|
7
|
+
Author-email: d3vr <hi@f3.al>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: System Administrators
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: System :: Networking
|
|
19
|
+
Requires-Dist: tomli>=2.0.0 ; python_full_version < '3.11'
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Project-URL: Homepage, https://github.com/d3vr/router-cli
|
|
22
|
+
Project-URL: Repository, https://github.com/d3vr/router-cli
|
|
23
|
+
Project-URL: Issues, https://github.com/d3vr/router-cli/issues
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
1
26
|
# router-cli
|
|
2
27
|
|
|
3
28
|
A command-line tool to manage and monitor D-Link DSL-2750U routers.
|
|
@@ -15,7 +40,7 @@ Since the router doesn't provide a formal API, this tool works by authenticating
|
|
|
15
40
|
|
|
16
41
|
## Installation
|
|
17
42
|
|
|
18
|
-
Requires Python 3.
|
|
43
|
+
Requires Python 3.10 or higher.
|
|
19
44
|
|
|
20
45
|
### Using uv (recommended)
|
|
21
46
|
|
|
@@ -48,10 +73,12 @@ username = "admin"
|
|
|
48
73
|
password = "your_password_here"
|
|
49
74
|
|
|
50
75
|
# Optional: Define known devices for colorized output
|
|
76
|
+
# Use MAC addresses for devices with stable MACs
|
|
77
|
+
# Use hostnames for devices with random MACs (like Android)
|
|
51
78
|
[known_devices]
|
|
52
79
|
"AA:BB:CC:DD:EE:FF" = "My Phone"
|
|
53
80
|
"11:22:33:44:55:66" = "Smart TV"
|
|
54
|
-
"
|
|
81
|
+
"android-abc123" = "John's Pixel"
|
|
55
82
|
```
|
|
56
83
|
|
|
57
84
|
The tool searches for configuration in the following locations (in order):
|
|
@@ -61,6 +88,24 @@ The tool searches for configuration in the following locations (in order):
|
|
|
61
88
|
|
|
62
89
|
## Usage
|
|
63
90
|
|
|
91
|
+
### Global Options
|
|
92
|
+
|
|
93
|
+
All commands support these global options:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
router --ip 192.168.1.1 --user admin --pass secret status
|
|
97
|
+
router --json clients
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
| Option | Environment Variable | Description |
|
|
101
|
+
|--------|---------------------|-------------|
|
|
102
|
+
| `--ip ADDRESS` | `ROUTER_IP` | Router IP address |
|
|
103
|
+
| `--user USERNAME` | `ROUTER_USER` | Username for authentication |
|
|
104
|
+
| `--pass PASSWORD` | `ROUTER_PASS` | Password for authentication |
|
|
105
|
+
| `--json` | - | Output in JSON format (for scripting) |
|
|
106
|
+
|
|
107
|
+
**Configuration priority**: CLI flags > environment variables > config file > defaults
|
|
108
|
+
|
|
64
109
|
### Quick Overview
|
|
65
110
|
|
|
66
111
|
```bash
|
|
@@ -179,7 +224,7 @@ Sends a reboot command to the router.
|
|
|
179
224
|
|
|
180
225
|
## Known Devices
|
|
181
226
|
|
|
182
|
-
The `[known_devices]` section in your config file maps MAC addresses to friendly names. This enables:
|
|
227
|
+
The `[known_devices]` section in your config file maps MAC addresses or hostnames to friendly names. This enables:
|
|
183
228
|
|
|
184
229
|
- **Color-coded output**: Known devices appear in green, unknown in red
|
|
185
230
|
- **Friendly names**: See "My Phone" instead of a hostname or MAC address
|
|
@@ -187,11 +232,18 @@ The `[known_devices]` section in your config file maps MAC addresses to friendly
|
|
|
187
232
|
|
|
188
233
|
```toml
|
|
189
234
|
[known_devices]
|
|
235
|
+
# By MAC address (for devices with stable MACs)
|
|
190
236
|
"AA:BB:CC:DD:EE:FF" = "My Phone"
|
|
191
237
|
"11:22:33:44:55:66" = "Smart TV"
|
|
238
|
+
|
|
239
|
+
# By hostname (for devices with random MACs, like some Android phones)
|
|
240
|
+
"android-abc123def" = "John's Pixel"
|
|
241
|
+
"Galaxy-S24" = "Sarah's Phone"
|
|
192
242
|
```
|
|
193
243
|
|
|
194
|
-
MAC addresses are matched case-insensitively.
|
|
244
|
+
**MAC addresses** are identified by their format (`XX:XX:XX:XX:XX:XX`) and matched case-insensitively.
|
|
245
|
+
|
|
246
|
+
**Hostnames** are useful for Android and iOS devices that use MAC address randomization for privacy. These devices typically maintain a consistent hostname even when their MAC changes. Hostnames are also matched case-insensitively.
|
|
195
247
|
|
|
196
248
|
## Development
|
|
197
249
|
|
|
@@ -208,7 +260,7 @@ uv run pytest
|
|
|
208
260
|
|
|
209
261
|
## Compatibility
|
|
210
262
|
|
|
211
|
-
- **Python**: 3.
|
|
263
|
+
- **Python**: 3.10+
|
|
212
264
|
- **Router**: D-Link DSL-2750U (firmware ME_1.00)
|
|
213
265
|
|
|
214
266
|
This tool may work with other D-Link routers that share a similar web interface, but has only been tested with the DSL-2750U.
|
|
@@ -1,13 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.3
|
|
2
|
-
Name: router-cli
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary: CLI tool to manage D-Link DSL-2750U router
|
|
5
|
-
Author: d3vr
|
|
6
|
-
Author-email: d3vr <hi@f3.al>
|
|
7
|
-
Requires-Dist: tomli>=2.0.0 ; python_full_version < '3.11'
|
|
8
|
-
Requires-Python: >=3.10
|
|
9
|
-
Description-Content-Type: text/markdown
|
|
10
|
-
|
|
11
1
|
# router-cli
|
|
12
2
|
|
|
13
3
|
A command-line tool to manage and monitor D-Link DSL-2750U routers.
|
|
@@ -25,7 +15,7 @@ Since the router doesn't provide a formal API, this tool works by authenticating
|
|
|
25
15
|
|
|
26
16
|
## Installation
|
|
27
17
|
|
|
28
|
-
Requires Python 3.
|
|
18
|
+
Requires Python 3.10 or higher.
|
|
29
19
|
|
|
30
20
|
### Using uv (recommended)
|
|
31
21
|
|
|
@@ -58,10 +48,12 @@ username = "admin"
|
|
|
58
48
|
password = "your_password_here"
|
|
59
49
|
|
|
60
50
|
# Optional: Define known devices for colorized output
|
|
51
|
+
# Use MAC addresses for devices with stable MACs
|
|
52
|
+
# Use hostnames for devices with random MACs (like Android)
|
|
61
53
|
[known_devices]
|
|
62
54
|
"AA:BB:CC:DD:EE:FF" = "My Phone"
|
|
63
55
|
"11:22:33:44:55:66" = "Smart TV"
|
|
64
|
-
"
|
|
56
|
+
"android-abc123" = "John's Pixel"
|
|
65
57
|
```
|
|
66
58
|
|
|
67
59
|
The tool searches for configuration in the following locations (in order):
|
|
@@ -71,6 +63,24 @@ The tool searches for configuration in the following locations (in order):
|
|
|
71
63
|
|
|
72
64
|
## Usage
|
|
73
65
|
|
|
66
|
+
### Global Options
|
|
67
|
+
|
|
68
|
+
All commands support these global options:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
router --ip 192.168.1.1 --user admin --pass secret status
|
|
72
|
+
router --json clients
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
| Option | Environment Variable | Description |
|
|
76
|
+
|--------|---------------------|-------------|
|
|
77
|
+
| `--ip ADDRESS` | `ROUTER_IP` | Router IP address |
|
|
78
|
+
| `--user USERNAME` | `ROUTER_USER` | Username for authentication |
|
|
79
|
+
| `--pass PASSWORD` | `ROUTER_PASS` | Password for authentication |
|
|
80
|
+
| `--json` | - | Output in JSON format (for scripting) |
|
|
81
|
+
|
|
82
|
+
**Configuration priority**: CLI flags > environment variables > config file > defaults
|
|
83
|
+
|
|
74
84
|
### Quick Overview
|
|
75
85
|
|
|
76
86
|
```bash
|
|
@@ -189,7 +199,7 @@ Sends a reboot command to the router.
|
|
|
189
199
|
|
|
190
200
|
## Known Devices
|
|
191
201
|
|
|
192
|
-
The `[known_devices]` section in your config file maps MAC addresses to friendly names. This enables:
|
|
202
|
+
The `[known_devices]` section in your config file maps MAC addresses or hostnames to friendly names. This enables:
|
|
193
203
|
|
|
194
204
|
- **Color-coded output**: Known devices appear in green, unknown in red
|
|
195
205
|
- **Friendly names**: See "My Phone" instead of a hostname or MAC address
|
|
@@ -197,11 +207,18 @@ The `[known_devices]` section in your config file maps MAC addresses to friendly
|
|
|
197
207
|
|
|
198
208
|
```toml
|
|
199
209
|
[known_devices]
|
|
210
|
+
# By MAC address (for devices with stable MACs)
|
|
200
211
|
"AA:BB:CC:DD:EE:FF" = "My Phone"
|
|
201
212
|
"11:22:33:44:55:66" = "Smart TV"
|
|
213
|
+
|
|
214
|
+
# By hostname (for devices with random MACs, like some Android phones)
|
|
215
|
+
"android-abc123def" = "John's Pixel"
|
|
216
|
+
"Galaxy-S24" = "Sarah's Phone"
|
|
202
217
|
```
|
|
203
218
|
|
|
204
|
-
MAC addresses are matched case-insensitively.
|
|
219
|
+
**MAC addresses** are identified by their format (`XX:XX:XX:XX:XX:XX`) and matched case-insensitively.
|
|
220
|
+
|
|
221
|
+
**Hostnames** are useful for Android and iOS devices that use MAC address randomization for privacy. These devices typically maintain a consistent hostname even when their MAC changes. Hostnames are also matched case-insensitively.
|
|
205
222
|
|
|
206
223
|
## Development
|
|
207
224
|
|
|
@@ -218,7 +235,7 @@ uv run pytest
|
|
|
218
235
|
|
|
219
236
|
## Compatibility
|
|
220
237
|
|
|
221
|
-
- **Python**: 3.
|
|
238
|
+
- **Python**: 3.10+
|
|
222
239
|
- **Router**: D-Link DSL-2750U (firmware ME_1.00)
|
|
223
240
|
|
|
224
241
|
This tool may work with other D-Link routers that share a similar web interface, but has only been tested with the DSL-2750U.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "router-cli"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "CLI tool to manage D-Link DSL-2750U router"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "d3vr", email = "hi@f3.al" }]
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
dependencies = ["tomli>=2.0.0; python_version < '3.11'"]
|
|
9
|
+
license = { text = "MIT" }
|
|
10
|
+
keywords = ["router", "cli", "dlink", "dsl-2750u", "network", "admin"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Intended Audience :: System Administrators",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Topic :: System :: Networking",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/d3vr/router-cli"
|
|
26
|
+
Repository = "https://github.com/d3vr/router-cli"
|
|
27
|
+
Issues = "https://github.com/d3vr/router-cli/issues"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
router = "router_cli.main:main"
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
dev = ["pytest>=8.0.0"]
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["uv_build>=0.9.28,<0.10.0"]
|
|
37
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""HTTP client for D-Link DSL-2750U router."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from http.cookiejar import CookieJar
|
|
9
|
+
|
|
10
|
+
from .models import (
|
|
11
|
+
ADSLStats,
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
ConnectionError,
|
|
14
|
+
DHCPLease,
|
|
15
|
+
HTTPError,
|
|
16
|
+
InterfaceStats,
|
|
17
|
+
LogEntry,
|
|
18
|
+
Route,
|
|
19
|
+
RouterError,
|
|
20
|
+
RouterStatus,
|
|
21
|
+
Statistics,
|
|
22
|
+
WANConnection,
|
|
23
|
+
WirelessClient,
|
|
24
|
+
)
|
|
25
|
+
from .parser import (
|
|
26
|
+
parse_dhcp_leases,
|
|
27
|
+
parse_logs,
|
|
28
|
+
parse_routes,
|
|
29
|
+
parse_statistics,
|
|
30
|
+
parse_status,
|
|
31
|
+
parse_wireless_clients,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Re-export models for backward compatibility
|
|
35
|
+
__all__ = [
|
|
36
|
+
"ADSLStats",
|
|
37
|
+
"AuthenticationError",
|
|
38
|
+
"ConnectionError",
|
|
39
|
+
"DHCPLease",
|
|
40
|
+
"HTTPError",
|
|
41
|
+
"InterfaceStats",
|
|
42
|
+
"LogEntry",
|
|
43
|
+
"Route",
|
|
44
|
+
"RouterClient",
|
|
45
|
+
"RouterError",
|
|
46
|
+
"RouterStatus",
|
|
47
|
+
"Statistics",
|
|
48
|
+
"WANConnection",
|
|
49
|
+
"WirelessClient",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RouterClient:
|
|
54
|
+
"""Client for communicating with D-Link DSL-2750U router."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, ip: str, username: str, password: str):
|
|
57
|
+
self.ip = ip
|
|
58
|
+
self.username = username
|
|
59
|
+
self.password = password
|
|
60
|
+
self.base_url = f"http://{ip}"
|
|
61
|
+
self.cookie_jar = CookieJar()
|
|
62
|
+
self.opener = urllib.request.build_opener(
|
|
63
|
+
urllib.request.HTTPCookieProcessor(self.cookie_jar)
|
|
64
|
+
)
|
|
65
|
+
self._authenticated = False
|
|
66
|
+
|
|
67
|
+
# Patterns that indicate a login/session expired page
|
|
68
|
+
_LOGIN_PAGE_PATTERNS = [
|
|
69
|
+
re.compile(r"<title>\s*Login\s*</title>", re.IGNORECASE),
|
|
70
|
+
re.compile(r'name=["\']?password["\']?.*type=["\']?password', re.IGNORECASE),
|
|
71
|
+
re.compile(r"session\s*(has\s*)?expired", re.IGNORECASE),
|
|
72
|
+
re.compile(r"please\s*log\s*in", re.IGNORECASE),
|
|
73
|
+
re.compile(r"unauthorized", re.IGNORECASE),
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
# Patterns that indicate an error page
|
|
77
|
+
_ERROR_PAGE_PATTERNS = [
|
|
78
|
+
re.compile(r"<title>\s*Error\s*</title>", re.IGNORECASE),
|
|
79
|
+
re.compile(r"internal\s*server\s*error", re.IGNORECASE),
|
|
80
|
+
re.compile(r"service\s*unavailable", re.IGNORECASE),
|
|
81
|
+
re.compile(r"<h1>\s*\d{3}\s*</h1>", re.IGNORECASE), # <h1>500</h1> etc.
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
def _is_login_page(self, html: str) -> bool:
|
|
85
|
+
"""Check if the HTML response is a login/session expired page."""
|
|
86
|
+
for pattern in self._LOGIN_PAGE_PATTERNS:
|
|
87
|
+
if pattern.search(html):
|
|
88
|
+
return True
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
def _is_error_page(self, html: str) -> tuple[bool, str | None]:
|
|
92
|
+
"""Check if the HTML response is an error page.
|
|
93
|
+
|
|
94
|
+
Returns (is_error, error_message).
|
|
95
|
+
"""
|
|
96
|
+
for pattern in self._ERROR_PAGE_PATTERNS:
|
|
97
|
+
if pattern.search(html):
|
|
98
|
+
# Try to extract a meaningful error message
|
|
99
|
+
title_match = re.search(r"<title>([^<]+)</title>", html, re.IGNORECASE)
|
|
100
|
+
h1_match = re.search(r"<h1>([^<]+)</h1>", html, re.IGNORECASE)
|
|
101
|
+
msg = (
|
|
102
|
+
title_match.group(1)
|
|
103
|
+
if title_match
|
|
104
|
+
else (h1_match.group(1) if h1_match else "Unknown error")
|
|
105
|
+
)
|
|
106
|
+
return True, msg.strip()
|
|
107
|
+
return False, None
|
|
108
|
+
|
|
109
|
+
def authenticate(self) -> bool:
|
|
110
|
+
"""Authenticate with the router.
|
|
111
|
+
|
|
112
|
+
POST to /main with credentials in cookies and form data.
|
|
113
|
+
"""
|
|
114
|
+
url = f"{self.base_url}/main"
|
|
115
|
+
|
|
116
|
+
# URL-encode the password for form data (+ becomes %2B)
|
|
117
|
+
encoded_password = urllib.parse.quote(self.password, safe="")
|
|
118
|
+
form_data = f"username={self.username}&password={encoded_password}&loginfo=on"
|
|
119
|
+
|
|
120
|
+
# Set auth cookies
|
|
121
|
+
cookie_header = f"username={self.username}; password={self.password}"
|
|
122
|
+
|
|
123
|
+
request = urllib.request.Request(
|
|
124
|
+
url,
|
|
125
|
+
data=form_data.encode("utf-8"),
|
|
126
|
+
headers={
|
|
127
|
+
"Cookie": cookie_header,
|
|
128
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
129
|
+
},
|
|
130
|
+
method="POST",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
with self.opener.open(request, timeout=10) as response:
|
|
135
|
+
html = response.read().decode("utf-8", errors="replace")
|
|
136
|
+
# Check if we got redirected to login page (auth failed)
|
|
137
|
+
if self._is_login_page(html):
|
|
138
|
+
self._authenticated = False
|
|
139
|
+
raise AuthenticationError(
|
|
140
|
+
"Authentication failed: invalid credentials"
|
|
141
|
+
)
|
|
142
|
+
self._authenticated = response.status == 200
|
|
143
|
+
return self._authenticated
|
|
144
|
+
except urllib.error.HTTPError as e:
|
|
145
|
+
raise AuthenticationError(f"Authentication failed: HTTP {e.code}")
|
|
146
|
+
except urllib.error.URLError as e:
|
|
147
|
+
raise ConnectionError(
|
|
148
|
+
f"Failed to connect to router at {self.ip}: {e.reason}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def fetch_page(self, path: str, max_retries: int = 3) -> str:
|
|
152
|
+
"""Fetch a page from the router with authentication cookies.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
path: The page path to fetch (e.g., "/info.html")
|
|
156
|
+
max_retries: Maximum number of retry attempts for transient failures
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
The HTML content of the page
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
AuthenticationError: If session expired and re-auth fails
|
|
163
|
+
ConnectionError: If unable to connect to the router
|
|
164
|
+
HTTPError: If the router returns an HTTP error
|
|
165
|
+
"""
|
|
166
|
+
if not self._authenticated:
|
|
167
|
+
self.authenticate()
|
|
168
|
+
|
|
169
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
170
|
+
cookie_header = f"username={self.username}; password={self.password}"
|
|
171
|
+
|
|
172
|
+
last_error: Exception | None = None
|
|
173
|
+
|
|
174
|
+
for attempt in range(max_retries):
|
|
175
|
+
request = urllib.request.Request(
|
|
176
|
+
url, headers={"Cookie": cookie_header}, method="GET"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
with self.opener.open(request, timeout=10) as response:
|
|
181
|
+
html = response.read().decode("utf-8", errors="replace")
|
|
182
|
+
|
|
183
|
+
# Check if we got a login page (session expired)
|
|
184
|
+
if self._is_login_page(html):
|
|
185
|
+
self._authenticated = False
|
|
186
|
+
# Try to re-authenticate once
|
|
187
|
+
if attempt == 0:
|
|
188
|
+
try:
|
|
189
|
+
self.authenticate()
|
|
190
|
+
continue # Retry the request
|
|
191
|
+
except AuthenticationError:
|
|
192
|
+
raise AuthenticationError(
|
|
193
|
+
"Session expired and re-authentication failed"
|
|
194
|
+
)
|
|
195
|
+
raise AuthenticationError("Session expired")
|
|
196
|
+
|
|
197
|
+
# Check if we got an error page
|
|
198
|
+
is_error, error_msg = self._is_error_page(html)
|
|
199
|
+
if is_error:
|
|
200
|
+
# Some error pages are transient, retry
|
|
201
|
+
if attempt < max_retries - 1:
|
|
202
|
+
time.sleep(1 * (attempt + 1)) # Backoff
|
|
203
|
+
continue
|
|
204
|
+
raise HTTPError(f"Router returned error page: {error_msg}")
|
|
205
|
+
|
|
206
|
+
return html
|
|
207
|
+
|
|
208
|
+
except urllib.error.HTTPError as e:
|
|
209
|
+
last_error = e
|
|
210
|
+
# Read the error body for better diagnostics
|
|
211
|
+
try:
|
|
212
|
+
error_body = e.read().decode("utf-8", errors="replace")[:200]
|
|
213
|
+
except Exception:
|
|
214
|
+
error_body = ""
|
|
215
|
+
|
|
216
|
+
# Retry on 5xx errors (server-side issues)
|
|
217
|
+
if 500 <= e.code < 600 and attempt < max_retries - 1:
|
|
218
|
+
time.sleep(1 * (attempt + 1)) # Exponential backoff
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
# Provide helpful error message based on status code
|
|
222
|
+
if e.code == 401:
|
|
223
|
+
self._authenticated = False
|
|
224
|
+
raise AuthenticationError("Authentication required (401)")
|
|
225
|
+
elif e.code == 403:
|
|
226
|
+
raise AuthenticationError("Access forbidden (403)")
|
|
227
|
+
elif e.code == 404:
|
|
228
|
+
raise HTTPError(f"Page not found: {path}", status_code=404)
|
|
229
|
+
elif e.code == 503:
|
|
230
|
+
raise HTTPError(
|
|
231
|
+
"Router is busy or unavailable (503). Try again later.",
|
|
232
|
+
status_code=503,
|
|
233
|
+
)
|
|
234
|
+
else:
|
|
235
|
+
# Include snippet of error body for debugging
|
|
236
|
+
snippet = error_body[:100].replace("\n", " ").strip()
|
|
237
|
+
raise HTTPError(
|
|
238
|
+
f"HTTP {e.code} fetching {path}: {snippet or e.reason}",
|
|
239
|
+
status_code=e.code,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
except urllib.error.URLError as e:
|
|
243
|
+
last_error = e
|
|
244
|
+
# Retry on network errors
|
|
245
|
+
if attempt < max_retries - 1:
|
|
246
|
+
time.sleep(1 * (attempt + 1))
|
|
247
|
+
continue
|
|
248
|
+
raise ConnectionError(f"Failed to connect to {self.ip}: {e.reason}")
|
|
249
|
+
|
|
250
|
+
except TimeoutError:
|
|
251
|
+
last_error = TimeoutError(f"Request to {path} timed out")
|
|
252
|
+
if attempt < max_retries - 1:
|
|
253
|
+
time.sleep(1 * (attempt + 1))
|
|
254
|
+
continue
|
|
255
|
+
raise ConnectionError(
|
|
256
|
+
f"Request to {path} timed out after {max_retries} attempts"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Should not reach here, but just in case
|
|
260
|
+
raise ConnectionError(
|
|
261
|
+
f"Failed to fetch {path} after {max_retries} attempts: {last_error}"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def get_session_key(self, html: str) -> str:
|
|
265
|
+
"""Extract session key from HTML page."""
|
|
266
|
+
match = re.search(r"var\s+sessionKey\s*=\s*[\"']([^\"']+)[\"']", html)
|
|
267
|
+
if match:
|
|
268
|
+
return match.group(1)
|
|
269
|
+
raise ValueError("Could not find session key in page")
|
|
270
|
+
|
|
271
|
+
def get_status(self) -> RouterStatus:
|
|
272
|
+
"""Fetch and parse router status."""
|
|
273
|
+
html = self.fetch_page("/info.html")
|
|
274
|
+
return parse_status(html)
|
|
275
|
+
|
|
276
|
+
def reboot(self) -> bool:
|
|
277
|
+
"""Reboot the router."""
|
|
278
|
+
# First get session key from internet.html
|
|
279
|
+
html = self.fetch_page("/internet.html")
|
|
280
|
+
session_key = self.get_session_key(html)
|
|
281
|
+
|
|
282
|
+
# POST to rebootinfo.cgi
|
|
283
|
+
url = f"{self.base_url}/rebootinfo.cgi?sessionKey={session_key}"
|
|
284
|
+
cookie_header = f"username={self.username}; password={self.password}"
|
|
285
|
+
|
|
286
|
+
request = urllib.request.Request(
|
|
287
|
+
url, headers={"Cookie": cookie_header}, method="POST", data=b""
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
with self.opener.open(request, timeout=10) as response:
|
|
292
|
+
return response.status == 200
|
|
293
|
+
except urllib.error.URLError:
|
|
294
|
+
# Router may disconnect during reboot, this is expected
|
|
295
|
+
return True
|
|
296
|
+
|
|
297
|
+
def get_wireless_clients(self) -> list[WirelessClient]:
|
|
298
|
+
"""Fetch and parse wireless clients."""
|
|
299
|
+
html = self.fetch_page("/wlstationlist.cmd")
|
|
300
|
+
return parse_wireless_clients(html)
|
|
301
|
+
|
|
302
|
+
def get_dhcp_leases(self) -> list[DHCPLease]:
|
|
303
|
+
"""Fetch and parse DHCP leases."""
|
|
304
|
+
html = self.fetch_page("/dhcpinfo.html")
|
|
305
|
+
return parse_dhcp_leases(html)
|
|
306
|
+
|
|
307
|
+
def get_routes(self) -> list[Route]:
|
|
308
|
+
"""Fetch and parse routing table."""
|
|
309
|
+
html = self.fetch_page("/rtroutecfg.cmd?action=dlinkau")
|
|
310
|
+
return parse_routes(html)
|
|
311
|
+
|
|
312
|
+
def get_statistics(self) -> Statistics:
|
|
313
|
+
"""Fetch and parse network statistics."""
|
|
314
|
+
html = self.fetch_page("/statsifcwanber.html")
|
|
315
|
+
return parse_statistics(html)
|
|
316
|
+
|
|
317
|
+
def get_logs(self) -> list[LogEntry]:
|
|
318
|
+
"""Fetch and parse system logs."""
|
|
319
|
+
html = self.fetch_page("/logview.cmd")
|
|
320
|
+
return parse_logs(html)
|