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.
@@ -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.11 or higher.
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
- "DE:AD:BE:EF:00:01" = "Work Laptop"
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.11+
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.11 or higher.
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
- "DE:AD:BE:EF:00:01" = "Work Laptop"
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.11+
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"
@@ -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)