router-cli 0.2.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.2.0 → router_cli-0.3.0}/PKG-INFO +21 -3
- {router_cli-0.2.0 → router_cli-0.3.0}/README.md +20 -2
- {router_cli-0.2.0 → router_cli-0.3.0}/pyproject.toml +1 -1
- {router_cli-0.2.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.2.0 → router_cli-0.3.0}/src/router_cli/config.py +75 -21
- router_cli-0.3.0/src/router_cli/display.py +191 -0
- router_cli-0.2.0/src/router_cli/main.py → router_cli-0.3.0/src/router_cli/formatters.py +7 -352
- 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.2.0/src/router_cli/client.py +0 -883
- {router_cli-0.2.0 → router_cli-0.3.0}/src/router_cli/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: router-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: CLI tool to manage D-Link DSL-2750U router
|
|
5
5
|
Keywords: router,cli,dlink,dsl-2750u,network,admin
|
|
6
6
|
Author: d3vr
|
|
@@ -40,7 +40,7 @@ Since the router doesn't provide a formal API, this tool works by authenticating
|
|
|
40
40
|
|
|
41
41
|
## Installation
|
|
42
42
|
|
|
43
|
-
Requires Python 3.
|
|
43
|
+
Requires Python 3.10 or higher.
|
|
44
44
|
|
|
45
45
|
### Using uv (recommended)
|
|
46
46
|
|
|
@@ -88,6 +88,24 @@ The tool searches for configuration in the following locations (in order):
|
|
|
88
88
|
|
|
89
89
|
## Usage
|
|
90
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
|
+
|
|
91
109
|
### Quick Overview
|
|
92
110
|
|
|
93
111
|
```bash
|
|
@@ -242,7 +260,7 @@ uv run pytest
|
|
|
242
260
|
|
|
243
261
|
## Compatibility
|
|
244
262
|
|
|
245
|
-
- **Python**: 3.
|
|
263
|
+
- **Python**: 3.10+
|
|
246
264
|
- **Router**: D-Link DSL-2750U (firmware ME_1.00)
|
|
247
265
|
|
|
248
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.
|
|
@@ -15,7 +15,7 @@ Since the router doesn't provide a formal API, this tool works by authenticating
|
|
|
15
15
|
|
|
16
16
|
## Installation
|
|
17
17
|
|
|
18
|
-
Requires Python 3.
|
|
18
|
+
Requires Python 3.10 or higher.
|
|
19
19
|
|
|
20
20
|
### Using uv (recommended)
|
|
21
21
|
|
|
@@ -63,6 +63,24 @@ The tool searches for configuration in the following locations (in order):
|
|
|
63
63
|
|
|
64
64
|
## Usage
|
|
65
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
|
+
|
|
66
84
|
### Quick Overview
|
|
67
85
|
|
|
68
86
|
```bash
|
|
@@ -217,7 +235,7 @@ uv run pytest
|
|
|
217
235
|
|
|
218
236
|
## Compatibility
|
|
219
237
|
|
|
220
|
-
- **Python**: 3.
|
|
238
|
+
- **Python**: 3.10+
|
|
221
239
|
- **Router**: D-Link DSL-2750U (firmware ME_1.00)
|
|
222
240
|
|
|
223
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,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)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Command handlers for the router CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
|
|
7
|
+
from .client import (
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
ConnectionError,
|
|
10
|
+
HTTPError,
|
|
11
|
+
RouterClient,
|
|
12
|
+
RouterError,
|
|
13
|
+
)
|
|
14
|
+
from .config import KnownDevices
|
|
15
|
+
from .display import spinner
|
|
16
|
+
from .formatters import (
|
|
17
|
+
format_clients,
|
|
18
|
+
format_dhcp,
|
|
19
|
+
format_logs,
|
|
20
|
+
format_overview,
|
|
21
|
+
format_routes,
|
|
22
|
+
format_stats,
|
|
23
|
+
format_status,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _handle_error(e: Exception, json_output: bool = False) -> int:
|
|
28
|
+
"""Handle exceptions and print user-friendly error messages.
|
|
29
|
+
|
|
30
|
+
Returns the exit code to use.
|
|
31
|
+
"""
|
|
32
|
+
error_info = {"error": str(e), "type": type(e).__name__}
|
|
33
|
+
|
|
34
|
+
if isinstance(e, AuthenticationError):
|
|
35
|
+
error_info["hint"] = "Check your username/password in the config file."
|
|
36
|
+
error_info["code"] = 2
|
|
37
|
+
elif isinstance(e, ConnectionError):
|
|
38
|
+
error_info["hint"] = "Ensure the router is reachable and the IP is correct."
|
|
39
|
+
error_info["code"] = 3
|
|
40
|
+
elif isinstance(e, HTTPError):
|
|
41
|
+
error_info["code"] = 4
|
|
42
|
+
if e.status_code == 503:
|
|
43
|
+
error_info["hint"] = "The router may be busy. Wait a moment and try again."
|
|
44
|
+
error_info["status_code"] = e.status_code
|
|
45
|
+
elif isinstance(e, RouterError):
|
|
46
|
+
error_info["code"] = 1
|
|
47
|
+
else:
|
|
48
|
+
error_info["code"] = 1
|
|
49
|
+
|
|
50
|
+
if json_output:
|
|
51
|
+
print(json.dumps(error_info, indent=2), file=sys.stderr)
|
|
52
|
+
else:
|
|
53
|
+
if isinstance(e, AuthenticationError):
|
|
54
|
+
print(f"Authentication error: {e}", file=sys.stderr)
|
|
55
|
+
print(
|
|
56
|
+
" Hint: Check your username/password in the config file.",
|
|
57
|
+
file=sys.stderr,
|
|
58
|
+
)
|
|
59
|
+
elif isinstance(e, ConnectionError):
|
|
60
|
+
print(f"Connection error: {e}", file=sys.stderr)
|
|
61
|
+
print(
|
|
62
|
+
" Hint: Ensure the router is reachable and the IP is correct.",
|
|
63
|
+
file=sys.stderr,
|
|
64
|
+
)
|
|
65
|
+
elif isinstance(e, HTTPError):
|
|
66
|
+
print(f"Router error: {e}", file=sys.stderr)
|
|
67
|
+
if e.status_code == 503:
|
|
68
|
+
print(
|
|
69
|
+
" Hint: The router may be busy. Wait a moment and try again.",
|
|
70
|
+
file=sys.stderr,
|
|
71
|
+
)
|
|
72
|
+
elif isinstance(e, RouterError):
|
|
73
|
+
print(f"Router error: {e}", file=sys.stderr)
|
|
74
|
+
else:
|
|
75
|
+
print(f"Unexpected error ({type(e).__name__}): {e}", file=sys.stderr)
|
|
76
|
+
|
|
77
|
+
return error_info["code"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _to_json(obj) -> str:
|
|
81
|
+
"""Convert dataclass or list of dataclasses to JSON."""
|
|
82
|
+
if isinstance(obj, list):
|
|
83
|
+
return json.dumps([asdict(item) for item in obj], indent=2)
|
|
84
|
+
return json.dumps(asdict(obj), indent=2)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cmd_status(client: RouterClient, json_output: bool = False) -> int:
|
|
88
|
+
"""Execute status command."""
|
|
89
|
+
try:
|
|
90
|
+
with spinner("Fetching router status..."):
|
|
91
|
+
status = client.get_status()
|
|
92
|
+
if json_output:
|
|
93
|
+
# Convert WANConnection objects to dicts for JSON
|
|
94
|
+
data = asdict(status)
|
|
95
|
+
print(json.dumps(data, indent=2))
|
|
96
|
+
else:
|
|
97
|
+
print(format_status(status))
|
|
98
|
+
return 0
|
|
99
|
+
except Exception as e:
|
|
100
|
+
return _handle_error(e, json_output)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def cmd_reboot(client: RouterClient) -> int:
|
|
104
|
+
"""Execute reboot command."""
|
|
105
|
+
try:
|
|
106
|
+
with spinner("Sending reboot command..."):
|
|
107
|
+
client.reboot()
|
|
108
|
+
print("Reboot command sent successfully.")
|
|
109
|
+
print("The router will restart in a few seconds.")
|
|
110
|
+
return 0
|
|
111
|
+
except Exception as e:
|
|
112
|
+
return _handle_error(e)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def cmd_clients(
|
|
116
|
+
client: RouterClient, known_devices: KnownDevices, json_output: bool = False
|
|
117
|
+
) -> int:
|
|
118
|
+
"""Execute clients command."""
|
|
119
|
+
try:
|
|
120
|
+
with spinner("Fetching wireless clients..."):
|
|
121
|
+
clients = client.get_wireless_clients()
|
|
122
|
+
if json_output:
|
|
123
|
+
print(_to_json(clients))
|
|
124
|
+
else:
|
|
125
|
+
print(format_clients(clients, known_devices))
|
|
126
|
+
return 0
|
|
127
|
+
except Exception as e:
|
|
128
|
+
return _handle_error(e, json_output)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cmd_dhcp(
|
|
132
|
+
client: RouterClient, known_devices: KnownDevices, json_output: bool = False
|
|
133
|
+
) -> int:
|
|
134
|
+
"""Execute dhcp command."""
|
|
135
|
+
try:
|
|
136
|
+
with spinner("Fetching DHCP leases..."):
|
|
137
|
+
leases = client.get_dhcp_leases()
|
|
138
|
+
if json_output:
|
|
139
|
+
print(_to_json(leases))
|
|
140
|
+
else:
|
|
141
|
+
print(format_dhcp(leases, known_devices))
|
|
142
|
+
return 0
|
|
143
|
+
except Exception as e:
|
|
144
|
+
return _handle_error(e, json_output)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_routes(client: RouterClient, json_output: bool = False) -> int:
|
|
148
|
+
"""Execute routes command."""
|
|
149
|
+
try:
|
|
150
|
+
with spinner("Fetching routing table..."):
|
|
151
|
+
routes = client.get_routes()
|
|
152
|
+
if json_output:
|
|
153
|
+
print(_to_json(routes))
|
|
154
|
+
else:
|
|
155
|
+
print(format_routes(routes))
|
|
156
|
+
return 0
|
|
157
|
+
except Exception as e:
|
|
158
|
+
return _handle_error(e, json_output)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def cmd_stats(client: RouterClient, json_output: bool = False) -> int:
|
|
162
|
+
"""Execute stats command."""
|
|
163
|
+
try:
|
|
164
|
+
with spinner("Fetching network statistics..."):
|
|
165
|
+
stats = client.get_statistics()
|
|
166
|
+
if json_output:
|
|
167
|
+
print(_to_json(stats))
|
|
168
|
+
else:
|
|
169
|
+
print(format_stats(stats))
|
|
170
|
+
return 0
|
|
171
|
+
except Exception as e:
|
|
172
|
+
return _handle_error(e, json_output)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def cmd_logs(
|
|
176
|
+
client: RouterClient,
|
|
177
|
+
tail: int | None = None,
|
|
178
|
+
level: str | None = None,
|
|
179
|
+
json_output: bool = False,
|
|
180
|
+
) -> int:
|
|
181
|
+
"""Execute logs command."""
|
|
182
|
+
try:
|
|
183
|
+
with spinner("Fetching system logs..."):
|
|
184
|
+
logs = client.get_logs()
|
|
185
|
+
|
|
186
|
+
# Filter by severity level if specified
|
|
187
|
+
if level:
|
|
188
|
+
level_upper = level.upper()
|
|
189
|
+
logs = [log for log in logs if log.severity.upper() == level_upper]
|
|
190
|
+
|
|
191
|
+
# Limit to last N entries if tail specified
|
|
192
|
+
if tail and tail > 0:
|
|
193
|
+
logs = logs[-tail:]
|
|
194
|
+
|
|
195
|
+
if json_output:
|
|
196
|
+
print(_to_json(logs))
|
|
197
|
+
else:
|
|
198
|
+
print(format_logs(logs))
|
|
199
|
+
return 0
|
|
200
|
+
except Exception as e:
|
|
201
|
+
return _handle_error(e, json_output)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cmd_overview(
|
|
205
|
+
client: RouterClient, known_devices: KnownDevices, json_output: bool = False
|
|
206
|
+
) -> int:
|
|
207
|
+
"""Execute overview command."""
|
|
208
|
+
try:
|
|
209
|
+
with spinner("Fetching router overview..."):
|
|
210
|
+
status = client.get_status()
|
|
211
|
+
clients = client.get_wireless_clients()
|
|
212
|
+
leases = client.get_dhcp_leases()
|
|
213
|
+
stats = client.get_statistics()
|
|
214
|
+
|
|
215
|
+
if json_output:
|
|
216
|
+
data = {
|
|
217
|
+
"status": asdict(status),
|
|
218
|
+
"wireless_clients": [asdict(c) for c in clients],
|
|
219
|
+
"dhcp_leases": [asdict(lease) for lease in leases],
|
|
220
|
+
"statistics": asdict(stats),
|
|
221
|
+
}
|
|
222
|
+
print(json.dumps(data, indent=2))
|
|
223
|
+
else:
|
|
224
|
+
print(format_overview(status, clients, leases, stats, known_devices))
|
|
225
|
+
return 0
|
|
226
|
+
except Exception as e:
|
|
227
|
+
return _handle_error(e, json_output)
|