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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: router-cli
3
- Version: 0.2.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.11 or higher.
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.11+
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.11 or higher.
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.11+
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "router-cli"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "CLI tool to manage D-Link DSL-2750U router"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "d3vr", email = "hi@f3.al" }]
@@ -1,3 +1,3 @@
1
1
  """Router CLI - Manage D-Link DSL-2750U router."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.3.0"
@@ -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)