AutoDialer 0.2.2__tar.gz → 0.2.3__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.
- {autodialer-0.2.2/src/AutoDialer.egg-info → autodialer-0.2.3}/PKG-INFO +9 -5
- {autodialer-0.2.2 → autodialer-0.2.3}/README.MD +9 -5
- {autodialer-0.2.2 → autodialer-0.2.3/src/AutoDialer.egg-info}/PKG-INFO +9 -5
- {autodialer-0.2.2 → autodialer-0.2.3}/src/AutoDialer.egg-info/SOURCES.txt +9 -3
- {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/__init__.py +3 -1
- {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/apis/__init__.py +3 -1
- autodialer-0.2.3/src/autodialer/apis/routers/asus/__init__.py +3 -0
- autodialer-0.2.3/src/autodialer/apis/routers/asus/asus_api.py +454 -0
- autodialer-0.2.3/src/autodialer/apis/routers/base_api.py +11 -0
- {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/apis/routers/tplink/tplink_api.py +31 -20
- {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/apis/utils/check_isp.py +17 -12
- autodialer-0.2.2/src/autodialer/apis/utils/check_router_vendor.py → autodialer-0.2.3/src/autodialer/apis/utils/check_vendor.py +11 -5
- autodialer-0.2.3/src/autodialer/apis/utils/get_gateway.py +199 -0
- autodialer-0.2.3/src/autodialer/apis/utils/get_vendor_api.py +75 -0
- autodialer-0.2.2/src/autodialer/apis/routers/tplink/tplink_get_devices.py → autodialer-0.2.3/src/autodialer/apis/utils/print_devices_table.py +0 -9
- {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/config/config.py +1 -0
- autodialer-0.2.3/src/autodialer/get_devices.py +37 -0
- {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/reconnection.py +28 -26
- autodialer-0.2.3/tests/test_asus_api.py +193 -0
- autodialer-0.2.3/tests/test_get_gateway.py +96 -0
- autodialer-0.2.3/tests/test_get_vendor_api.py +39 -0
- {autodialer-0.2.2 → autodialer-0.2.3}/tests/test_reconnection.py +20 -1
- autodialer-0.2.3/tests/test_tplink_api.py +105 -0
- autodialer-0.2.2/src/autodialer/apis/utils/get_gateway.py +0 -39
- autodialer-0.2.2/src/autodialer/apis/utils/get_vendor_api.py +0 -12
- autodialer-0.2.2/src/autodialer/get_devices.py +0 -23
- autodialer-0.2.2/tests/test_get_gateway.py +0 -51
- {autodialer-0.2.2 → autodialer-0.2.3}/LICENSE +0 -0
- {autodialer-0.2.2 → autodialer-0.2.3}/pyproject.toml +0 -0
- {autodialer-0.2.2 → autodialer-0.2.3}/setup.cfg +0 -0
- {autodialer-0.2.2 → autodialer-0.2.3}/src/AutoDialer.egg-info/dependency_links.txt +0 -0
- {autodialer-0.2.2 → autodialer-0.2.3}/src/AutoDialer.egg-info/entry_points.txt +0 -0
- {autodialer-0.2.2 → autodialer-0.2.3}/src/AutoDialer.egg-info/requires.txt +0 -0
- {autodialer-0.2.2 → autodialer-0.2.3}/src/AutoDialer.egg-info/top_level.txt +0 -0
- {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/apis/routers/__init__.py +0 -0
- {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/apis/routers/tplink/__init__.py +0 -0
- {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/apis/utils/is_target_asn.py +0 -0
- {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/config/__init__.py +0 -0
- {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/encode/__init__.py +0 -0
- {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/encode/tplink_security_encode.py +0 -0
- {autodialer-0.2.2 → autodialer-0.2.3}/tests/test_check_isp.py +0 -0
- {autodialer-0.2.2 → autodialer-0.2.3}/tests/test_pppoe.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: AutoDialer
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: AutoDialer is an automation script designed to interact with routers using APIs.
|
|
5
5
|
Author-email: Byte Flow <fakeshadow1337@gmail.com>
|
|
6
6
|
License-Expression: GPL-3.0-only
|
|
@@ -76,13 +76,15 @@ Create a `.env` file in your working directory:
|
|
|
76
76
|
|
|
77
77
|
| Variable | Description |
|
|
78
78
|
| :--- | :--- |
|
|
79
|
+
| `PANEL_USERNAME` | Router panel username (defaults to `admin`) |
|
|
79
80
|
| `PANEL_PASSWORD` | Router panel password |
|
|
80
|
-
| `PPPOE_USERNAME` | ISP PPPoE username (
|
|
81
|
-
| `PPPOE_PASSWORD` | ISP PPPoE password (
|
|
81
|
+
| `PPPOE_USERNAME` | ISP PPPoE username (optional; used when you want to overwrite the router's saved PPPoE config before reconnecting) |
|
|
82
|
+
| `PPPOE_PASSWORD` | ISP PPPoE password (optional; used when you want to overwrite the router's saved PPPoE config before reconnecting) |
|
|
82
83
|
| `ASN` | Target ASN (optional unless not using `--force`) |
|
|
83
84
|
|
|
84
85
|
Example:
|
|
85
86
|
```bash
|
|
87
|
+
PANEL_USERNAME='admin'
|
|
86
88
|
PANEL_PASSWORD='your_router_panel_password'
|
|
87
89
|
PPPOE_USERNAME='your_pppoe_username'
|
|
88
90
|
PPPOE_PASSWORD='your_pppoe_password'
|
|
@@ -106,11 +108,13 @@ Arguments:
|
|
|
106
108
|
|
|
107
109
|
Behavior:
|
|
108
110
|
- AutoDialer detects current WAN protocol and applies matching reconnection action.
|
|
109
|
-
- PPPoE uses disconnect/connect flow
|
|
111
|
+
- PPPoE uses disconnect/connect flow and reuses the router's saved credentials by default.
|
|
112
|
+
- If `PPPOE_USERNAME` and `PPPOE_PASSWORD` are set, AutoDialer updates the router's PPPoE config before reconnecting.
|
|
110
113
|
- DHCP uses DHCP renew flow and shares the same ASN/check/retry control logic.
|
|
114
|
+
- ASUSWRT routers use panel authentication plus WAN restart through the ASUS web API.
|
|
111
115
|
|
|
112
116
|
## Notes
|
|
113
|
-
-
|
|
117
|
+
- TP-Link and ASUS routers are supported by default.
|
|
114
118
|
- Keep `.env` private and never commit credentials.
|
|
115
119
|
|
|
116
120
|
---
|
|
@@ -52,13 +52,15 @@ Create a `.env` file in your working directory:
|
|
|
52
52
|
|
|
53
53
|
| Variable | Description |
|
|
54
54
|
| :--- | :--- |
|
|
55
|
+
| `PANEL_USERNAME` | Router panel username (defaults to `admin`) |
|
|
55
56
|
| `PANEL_PASSWORD` | Router panel password |
|
|
56
|
-
| `PPPOE_USERNAME` | ISP PPPoE username (
|
|
57
|
-
| `PPPOE_PASSWORD` | ISP PPPoE password (
|
|
57
|
+
| `PPPOE_USERNAME` | ISP PPPoE username (optional; used when you want to overwrite the router's saved PPPoE config before reconnecting) |
|
|
58
|
+
| `PPPOE_PASSWORD` | ISP PPPoE password (optional; used when you want to overwrite the router's saved PPPoE config before reconnecting) |
|
|
58
59
|
| `ASN` | Target ASN (optional unless not using `--force`) |
|
|
59
60
|
|
|
60
61
|
Example:
|
|
61
62
|
```bash
|
|
63
|
+
PANEL_USERNAME='admin'
|
|
62
64
|
PANEL_PASSWORD='your_router_panel_password'
|
|
63
65
|
PPPOE_USERNAME='your_pppoe_username'
|
|
64
66
|
PPPOE_PASSWORD='your_pppoe_password'
|
|
@@ -82,12 +84,14 @@ Arguments:
|
|
|
82
84
|
|
|
83
85
|
Behavior:
|
|
84
86
|
- AutoDialer detects current WAN protocol and applies matching reconnection action.
|
|
85
|
-
- PPPoE uses disconnect/connect flow
|
|
87
|
+
- PPPoE uses disconnect/connect flow and reuses the router's saved credentials by default.
|
|
88
|
+
- If `PPPOE_USERNAME` and `PPPOE_PASSWORD` are set, AutoDialer updates the router's PPPoE config before reconnecting.
|
|
86
89
|
- DHCP uses DHCP renew flow and shares the same ASN/check/retry control logic.
|
|
90
|
+
- ASUSWRT routers use panel authentication plus WAN restart through the ASUS web API.
|
|
87
91
|
|
|
88
92
|
## Notes
|
|
89
|
-
-
|
|
93
|
+
- TP-Link and ASUS routers are supported by default.
|
|
90
94
|
- Keep `.env` private and never commit credentials.
|
|
91
95
|
|
|
92
96
|
---
|
|
93
|
-
Thanks for using AutoDialer.
|
|
97
|
+
Thanks for using AutoDialer.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: AutoDialer
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: AutoDialer is an automation script designed to interact with routers using APIs.
|
|
5
5
|
Author-email: Byte Flow <fakeshadow1337@gmail.com>
|
|
6
6
|
License-Expression: GPL-3.0-only
|
|
@@ -76,13 +76,15 @@ Create a `.env` file in your working directory:
|
|
|
76
76
|
|
|
77
77
|
| Variable | Description |
|
|
78
78
|
| :--- | :--- |
|
|
79
|
+
| `PANEL_USERNAME` | Router panel username (defaults to `admin`) |
|
|
79
80
|
| `PANEL_PASSWORD` | Router panel password |
|
|
80
|
-
| `PPPOE_USERNAME` | ISP PPPoE username (
|
|
81
|
-
| `PPPOE_PASSWORD` | ISP PPPoE password (
|
|
81
|
+
| `PPPOE_USERNAME` | ISP PPPoE username (optional; used when you want to overwrite the router's saved PPPoE config before reconnecting) |
|
|
82
|
+
| `PPPOE_PASSWORD` | ISP PPPoE password (optional; used when you want to overwrite the router's saved PPPoE config before reconnecting) |
|
|
82
83
|
| `ASN` | Target ASN (optional unless not using `--force`) |
|
|
83
84
|
|
|
84
85
|
Example:
|
|
85
86
|
```bash
|
|
87
|
+
PANEL_USERNAME='admin'
|
|
86
88
|
PANEL_PASSWORD='your_router_panel_password'
|
|
87
89
|
PPPOE_USERNAME='your_pppoe_username'
|
|
88
90
|
PPPOE_PASSWORD='your_pppoe_password'
|
|
@@ -106,11 +108,13 @@ Arguments:
|
|
|
106
108
|
|
|
107
109
|
Behavior:
|
|
108
110
|
- AutoDialer detects current WAN protocol and applies matching reconnection action.
|
|
109
|
-
- PPPoE uses disconnect/connect flow
|
|
111
|
+
- PPPoE uses disconnect/connect flow and reuses the router's saved credentials by default.
|
|
112
|
+
- If `PPPOE_USERNAME` and `PPPOE_PASSWORD` are set, AutoDialer updates the router's PPPoE config before reconnecting.
|
|
110
113
|
- DHCP uses DHCP renew flow and shares the same ASN/check/retry control logic.
|
|
114
|
+
- ASUSWRT routers use panel authentication plus WAN restart through the ASUS web API.
|
|
111
115
|
|
|
112
116
|
## Notes
|
|
113
|
-
-
|
|
117
|
+
- TP-Link and ASUS routers are supported by default.
|
|
114
118
|
- Keep `.env` private and never commit credentials.
|
|
115
119
|
|
|
116
120
|
---
|
|
@@ -12,19 +12,25 @@ src/autodialer/get_devices.py
|
|
|
12
12
|
src/autodialer/reconnection.py
|
|
13
13
|
src/autodialer/apis/__init__.py
|
|
14
14
|
src/autodialer/apis/routers/__init__.py
|
|
15
|
+
src/autodialer/apis/routers/base_api.py
|
|
16
|
+
src/autodialer/apis/routers/asus/__init__.py
|
|
17
|
+
src/autodialer/apis/routers/asus/asus_api.py
|
|
15
18
|
src/autodialer/apis/routers/tplink/__init__.py
|
|
16
19
|
src/autodialer/apis/routers/tplink/tplink_api.py
|
|
17
|
-
src/autodialer/apis/routers/tplink/tplink_get_devices.py
|
|
18
20
|
src/autodialer/apis/utils/check_isp.py
|
|
19
|
-
src/autodialer/apis/utils/
|
|
21
|
+
src/autodialer/apis/utils/check_vendor.py
|
|
20
22
|
src/autodialer/apis/utils/get_gateway.py
|
|
21
23
|
src/autodialer/apis/utils/get_vendor_api.py
|
|
22
24
|
src/autodialer/apis/utils/is_target_asn.py
|
|
25
|
+
src/autodialer/apis/utils/print_devices_table.py
|
|
23
26
|
src/autodialer/config/__init__.py
|
|
24
27
|
src/autodialer/config/config.py
|
|
25
28
|
src/autodialer/encode/__init__.py
|
|
26
29
|
src/autodialer/encode/tplink_security_encode.py
|
|
30
|
+
tests/test_asus_api.py
|
|
27
31
|
tests/test_check_isp.py
|
|
28
32
|
tests/test_get_gateway.py
|
|
33
|
+
tests/test_get_vendor_api.py
|
|
29
34
|
tests/test_pppoe.py
|
|
30
|
-
tests/test_reconnection.py
|
|
35
|
+
tests/test_reconnection.py
|
|
36
|
+
tests/test_tplink_api.py
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
__version__ = "0.2.
|
|
1
|
+
__version__ = "0.2.3"
|
|
2
2
|
|
|
3
3
|
from .apis.utils.check_isp import check_isp, check_isp_with_retries
|
|
4
4
|
from .apis.utils.get_gateway import (
|
|
@@ -6,10 +6,12 @@ from .apis.utils.get_gateway import (
|
|
|
6
6
|
get_gateway_ip_on_unix,
|
|
7
7
|
get_gateway_ip_on_windows,
|
|
8
8
|
)
|
|
9
|
+
from .apis.routers.asus.asus_api import AsusAPI
|
|
9
10
|
from .apis.routers.tplink.tplink_api import TPLinkAPI
|
|
10
11
|
from .apis.utils.is_target_asn import is_target_asn
|
|
11
12
|
|
|
12
13
|
__all__ = [
|
|
14
|
+
"AsusAPI",
|
|
13
15
|
"check_isp",
|
|
14
16
|
"check_isp_with_retries",
|
|
15
17
|
"get_gateway_ip_on_linux",
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
from .routers.asus.asus_api import AsusAPI
|
|
1
2
|
from .routers.tplink.tplink_api import TPLinkAPI
|
|
2
3
|
from .utils.check_isp import check_isp
|
|
3
4
|
from .utils.check_isp import check_isp_with_retries
|
|
4
5
|
from .utils.get_gateway import get_gateway_ip
|
|
5
|
-
from .utils.
|
|
6
|
+
from .utils.check_vendor import check_router_vendor
|
|
6
7
|
|
|
7
8
|
__all__ = [
|
|
9
|
+
"AsusAPI",
|
|
8
10
|
"TPLinkAPI",
|
|
9
11
|
"check_isp",
|
|
10
12
|
"check_isp_with_retries",
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from autodialer.apis.utils.get_gateway import format_ip_for_url_host, get_gateway_ip
|
|
11
|
+
from autodialer.config.config import PANEL_PASSWORD, PANEL_USERNAME
|
|
12
|
+
|
|
13
|
+
USER_AGENT = "AutoDialer"
|
|
14
|
+
REQUEST_TIMEOUT = 5
|
|
15
|
+
WAN_STATUS_HOOK = "get_wan_unit();nvram_get(wan0_proto);nvram_get(wan1_proto);"
|
|
16
|
+
UPDATE_CLIENTS_PATTERN = re.compile(
|
|
17
|
+
r"originData = (.*)networkmap_fullscan = ",
|
|
18
|
+
re.DOTALL,
|
|
19
|
+
)
|
|
20
|
+
MAC_ADDRESS_PATTERN = re.compile(r"^(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AsusAPI:
|
|
27
|
+
"""Interact with ASUSWRT routers using the web API."""
|
|
28
|
+
|
|
29
|
+
SUPPORTED_VENDORS = ("ASUS", "ASUS AiMesh")
|
|
30
|
+
|
|
31
|
+
router_ip: str
|
|
32
|
+
panel_username: str
|
|
33
|
+
panel_password: str
|
|
34
|
+
base_url: str
|
|
35
|
+
token: str
|
|
36
|
+
verify_ssl: bool
|
|
37
|
+
|
|
38
|
+
def __init__(self):
|
|
39
|
+
router_ip = get_gateway_ip()
|
|
40
|
+
if router_ip is None:
|
|
41
|
+
logger.error("Could not determine router IP address.")
|
|
42
|
+
exit(1)
|
|
43
|
+
|
|
44
|
+
if PANEL_PASSWORD is None:
|
|
45
|
+
logger.error("Missing required environment variable: PANEL_PASSWORD")
|
|
46
|
+
exit(1)
|
|
47
|
+
|
|
48
|
+
self.router_ip = router_ip
|
|
49
|
+
self.panel_username = PANEL_USERNAME or "admin"
|
|
50
|
+
self.panel_password = PANEL_PASSWORD
|
|
51
|
+
self.session = requests.Session()
|
|
52
|
+
self.base_url, self.verify_ssl, self.token = self._login_router()
|
|
53
|
+
|
|
54
|
+
def _candidate_base_urls(self) -> list[tuple[str, bool]]:
|
|
55
|
+
router_host = format_ip_for_url_host(self.router_ip)
|
|
56
|
+
return [
|
|
57
|
+
(f"http://{router_host}", True),
|
|
58
|
+
(f"https://{router_host}", False),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def _dict_to_request(data: dict[str, Any]) -> str:
|
|
63
|
+
parts: list[str] = []
|
|
64
|
+
for key, value in data.items():
|
|
65
|
+
safe_key = str(key).replace("'", "\\'")
|
|
66
|
+
safe_value = str(value).replace("'", "\\'")
|
|
67
|
+
parts.append(f"'{safe_key}':'{safe_value}'")
|
|
68
|
+
return ";".join(parts)
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def _read_json_response(response: requests.Response) -> dict[str, Any] | None:
|
|
72
|
+
try:
|
|
73
|
+
data = response.json()
|
|
74
|
+
except ValueError:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
return data if isinstance(data, dict) else None
|
|
78
|
+
|
|
79
|
+
def _post_request(
|
|
80
|
+
self,
|
|
81
|
+
endpoint: str,
|
|
82
|
+
raw_payload: str,
|
|
83
|
+
form_payload: dict[str, Any] | None = None,
|
|
84
|
+
require_json: bool = True,
|
|
85
|
+
base_url: str | None = None,
|
|
86
|
+
verify_ssl: bool | None = None,
|
|
87
|
+
headers: dict[str, str] | None = None,
|
|
88
|
+
) -> tuple[bool, dict[str, Any] | None]:
|
|
89
|
+
url = f"{base_url or self.base_url}/{endpoint}"
|
|
90
|
+
ssl_verify = self.verify_ssl if verify_ssl is None else verify_ssl
|
|
91
|
+
payload_variants: list[Any] = [quote(raw_payload)]
|
|
92
|
+
if form_payload is not None:
|
|
93
|
+
payload_variants.append(form_payload)
|
|
94
|
+
|
|
95
|
+
last_error: requests.RequestException | None = None
|
|
96
|
+
|
|
97
|
+
for payload in payload_variants:
|
|
98
|
+
try:
|
|
99
|
+
response = self.session.post(
|
|
100
|
+
url,
|
|
101
|
+
data=payload,
|
|
102
|
+
headers=headers,
|
|
103
|
+
timeout=REQUEST_TIMEOUT,
|
|
104
|
+
verify=ssl_verify,
|
|
105
|
+
)
|
|
106
|
+
response.raise_for_status()
|
|
107
|
+
except requests.RequestException as exc:
|
|
108
|
+
last_error = exc
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
data = self._read_json_response(response)
|
|
112
|
+
if data is None and require_json:
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
return True, data
|
|
116
|
+
|
|
117
|
+
if last_error is not None:
|
|
118
|
+
logger.error("Error connecting to router: %s", last_error)
|
|
119
|
+
|
|
120
|
+
return False, None
|
|
121
|
+
|
|
122
|
+
def _post_text_request(
|
|
123
|
+
self,
|
|
124
|
+
endpoint: str,
|
|
125
|
+
raw_payload: str,
|
|
126
|
+
form_payload: dict[str, Any] | None = None,
|
|
127
|
+
base_url: str | None = None,
|
|
128
|
+
verify_ssl: bool | None = None,
|
|
129
|
+
headers: dict[str, str] | None = None,
|
|
130
|
+
) -> tuple[bool, str | None]:
|
|
131
|
+
url = f"{base_url or self.base_url}/{endpoint}"
|
|
132
|
+
ssl_verify = self.verify_ssl if verify_ssl is None else verify_ssl
|
|
133
|
+
payload_variants: list[Any] = [quote(raw_payload)]
|
|
134
|
+
if form_payload is not None:
|
|
135
|
+
payload_variants.append(form_payload)
|
|
136
|
+
|
|
137
|
+
last_error: requests.RequestException | None = None
|
|
138
|
+
|
|
139
|
+
for payload in payload_variants:
|
|
140
|
+
try:
|
|
141
|
+
response = self.session.post(
|
|
142
|
+
url,
|
|
143
|
+
data=payload,
|
|
144
|
+
headers=headers,
|
|
145
|
+
timeout=REQUEST_TIMEOUT,
|
|
146
|
+
verify=ssl_verify,
|
|
147
|
+
)
|
|
148
|
+
response.raise_for_status()
|
|
149
|
+
except requests.RequestException as exc:
|
|
150
|
+
last_error = exc
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
return True, response.text
|
|
154
|
+
|
|
155
|
+
if last_error is not None:
|
|
156
|
+
logger.error("Error connecting to router: %s", last_error)
|
|
157
|
+
|
|
158
|
+
return False, None
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _read_dict_json(content: str) -> dict[str, Any]:
|
|
162
|
+
try:
|
|
163
|
+
data = json.loads(content)
|
|
164
|
+
except json.JSONDecodeError:
|
|
165
|
+
return {}
|
|
166
|
+
|
|
167
|
+
return data if isinstance(data, dict) else {}
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def _read_update_clients_data(cls, content: str) -> dict[str, Any]:
|
|
171
|
+
match = UPDATE_CLIENTS_PATTERN.search(content.replace("\n", ""))
|
|
172
|
+
if not match:
|
|
173
|
+
return {}
|
|
174
|
+
|
|
175
|
+
payload = re.sub(
|
|
176
|
+
r"\b(fromNetworkmapd|nmpClient)\b\s*:",
|
|
177
|
+
r'"\1":',
|
|
178
|
+
match.group(1),
|
|
179
|
+
)
|
|
180
|
+
return cls._read_dict_json(payload)
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def _read_client_map(value: Any) -> dict[str, Any]:
|
|
184
|
+
if isinstance(value, list) and value and isinstance(value[0], dict):
|
|
185
|
+
return value[0]
|
|
186
|
+
if isinstance(value, dict):
|
|
187
|
+
return value
|
|
188
|
+
return {}
|
|
189
|
+
|
|
190
|
+
@classmethod
|
|
191
|
+
def _is_mac_address(cls, value: Any) -> bool:
|
|
192
|
+
return (
|
|
193
|
+
isinstance(value, str) and MAC_ADDRESS_PATTERN.fullmatch(value) is not None
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def _merge_client_metadata(
|
|
198
|
+
client: dict[str, Any],
|
|
199
|
+
fallback: dict[str, Any] | None,
|
|
200
|
+
) -> dict[str, Any]:
|
|
201
|
+
if not fallback:
|
|
202
|
+
return client
|
|
203
|
+
|
|
204
|
+
merged = client.copy()
|
|
205
|
+
for key in (
|
|
206
|
+
"name",
|
|
207
|
+
"nickName",
|
|
208
|
+
"vendor",
|
|
209
|
+
"vendorclass",
|
|
210
|
+
"type",
|
|
211
|
+
"defaultType",
|
|
212
|
+
):
|
|
213
|
+
if merged.get(key) in (None, "") and fallback.get(key) not in (
|
|
214
|
+
None,
|
|
215
|
+
"",
|
|
216
|
+
):
|
|
217
|
+
merged[key] = fallback[key]
|
|
218
|
+
|
|
219
|
+
return merged
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
def _read_device_name(client: dict[str, Any]) -> str:
|
|
223
|
+
for key in ("nickName", "name", "vendor"):
|
|
224
|
+
value = client.get(key)
|
|
225
|
+
if isinstance(value, str):
|
|
226
|
+
value = value.strip()
|
|
227
|
+
if value:
|
|
228
|
+
return value
|
|
229
|
+
|
|
230
|
+
return "(unknown)"
|
|
231
|
+
|
|
232
|
+
@staticmethod
|
|
233
|
+
def _read_speed(value: Any) -> int:
|
|
234
|
+
if isinstance(value, (int, float)):
|
|
235
|
+
return max(0, int(round(value)))
|
|
236
|
+
|
|
237
|
+
if isinstance(value, str):
|
|
238
|
+
value = value.strip()
|
|
239
|
+
if not value:
|
|
240
|
+
return 0
|
|
241
|
+
try:
|
|
242
|
+
return max(0, int(round(float(value))))
|
|
243
|
+
except ValueError:
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
return 0
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def _is_online(client: dict[str, Any]) -> bool:
|
|
250
|
+
return str(client.get("isOnline", client.get("online", "0"))).strip() == "1"
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def _read_connection_type(client: dict[str, Any]) -> str:
|
|
254
|
+
value = client.get(
|
|
255
|
+
"isWL",
|
|
256
|
+
client.get("wireless", client.get("is_wireless", "0")),
|
|
257
|
+
)
|
|
258
|
+
return "wireless" if str(value).strip() not in ("", "0", "None") else "wired"
|
|
259
|
+
|
|
260
|
+
def _login_router(self) -> tuple[str, bool, str]:
|
|
261
|
+
auth = f"{self.panel_username}:{self.panel_password}".encode("ascii")
|
|
262
|
+
token_value = base64.b64encode(auth).decode("ascii")
|
|
263
|
+
raw_payload = f"login_authorization={token_value}"
|
|
264
|
+
form_payload = {"login_authorization": token_value}
|
|
265
|
+
headers = {"user-agent": USER_AGENT}
|
|
266
|
+
|
|
267
|
+
for base_url, verify_ssl in self._candidate_base_urls():
|
|
268
|
+
ok, data = self._post_request(
|
|
269
|
+
endpoint="login.cgi",
|
|
270
|
+
raw_payload=raw_payload,
|
|
271
|
+
form_payload=form_payload,
|
|
272
|
+
require_json=True,
|
|
273
|
+
base_url=base_url,
|
|
274
|
+
verify_ssl=verify_ssl,
|
|
275
|
+
headers=headers,
|
|
276
|
+
)
|
|
277
|
+
if not ok or not data:
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
token = data.get("asus_token")
|
|
281
|
+
if isinstance(token, str) and token:
|
|
282
|
+
return base_url, verify_ssl, token
|
|
283
|
+
|
|
284
|
+
logger.error("Login failed.")
|
|
285
|
+
exit(1)
|
|
286
|
+
|
|
287
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
288
|
+
return {
|
|
289
|
+
"user-agent": USER_AGENT,
|
|
290
|
+
"cookie": f"asus_token={self.token}",
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
def get_wan_status(self) -> dict[str, Any]:
|
|
294
|
+
ok, data = self._post_request(
|
|
295
|
+
endpoint="appGet.cgi",
|
|
296
|
+
raw_payload=f"hook={WAN_STATUS_HOOK}",
|
|
297
|
+
form_payload={"hook": WAN_STATUS_HOOK},
|
|
298
|
+
require_json=True,
|
|
299
|
+
headers=self._auth_headers(),
|
|
300
|
+
)
|
|
301
|
+
if not ok or not data:
|
|
302
|
+
logger.error("Failed to get ASUS WAN status.")
|
|
303
|
+
return {}
|
|
304
|
+
|
|
305
|
+
return data
|
|
306
|
+
|
|
307
|
+
@staticmethod
|
|
308
|
+
def _read_wan_unit(status: dict[str, Any]) -> int | None:
|
|
309
|
+
raw_unit = status.get("get_wan_unit")
|
|
310
|
+
if isinstance(raw_unit, int):
|
|
311
|
+
return raw_unit
|
|
312
|
+
if isinstance(raw_unit, str) and raw_unit.isdigit():
|
|
313
|
+
return int(raw_unit)
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
def get_wan_proto(self) -> str | None:
|
|
317
|
+
status = self.get_wan_status()
|
|
318
|
+
if not status:
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
unit = self._read_wan_unit(status)
|
|
322
|
+
candidates: list[Any] = []
|
|
323
|
+
if unit in (0, 1):
|
|
324
|
+
candidates.append(status.get(f"wan{unit}_proto"))
|
|
325
|
+
candidates.extend([status.get("wan0_proto"), status.get("wan1_proto")])
|
|
326
|
+
|
|
327
|
+
for proto in candidates:
|
|
328
|
+
if isinstance(proto, str) and proto:
|
|
329
|
+
return proto.lower()
|
|
330
|
+
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
def _run_service(self, service: str) -> bool:
|
|
334
|
+
payload = {
|
|
335
|
+
"rc_service": service,
|
|
336
|
+
"action_mode": "apply",
|
|
337
|
+
}
|
|
338
|
+
ok, data = self._post_request(
|
|
339
|
+
endpoint="applyapp.cgi",
|
|
340
|
+
raw_payload=self._dict_to_request(payload),
|
|
341
|
+
form_payload=payload,
|
|
342
|
+
require_json=False,
|
|
343
|
+
headers=self._auth_headers(),
|
|
344
|
+
)
|
|
345
|
+
if not ok:
|
|
346
|
+
return False
|
|
347
|
+
|
|
348
|
+
if not data:
|
|
349
|
+
return True
|
|
350
|
+
|
|
351
|
+
error_status = data.get("error_status")
|
|
352
|
+
if error_status not in (None, "", 0, "0", False):
|
|
353
|
+
logger.error("ASUS service error: %s", error_status)
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
run_service = data.get("run_service")
|
|
357
|
+
if not isinstance(run_service, str) or not run_service:
|
|
358
|
+
return True
|
|
359
|
+
|
|
360
|
+
accepted_services = {service}
|
|
361
|
+
if service.startswith("restart_wan"):
|
|
362
|
+
accepted_services.add("restart_wan")
|
|
363
|
+
|
|
364
|
+
if run_service not in accepted_services:
|
|
365
|
+
logger.error(
|
|
366
|
+
"Unexpected ASUS service response: expected %s, got %s.",
|
|
367
|
+
service,
|
|
368
|
+
run_service,
|
|
369
|
+
)
|
|
370
|
+
return False
|
|
371
|
+
|
|
372
|
+
return True
|
|
373
|
+
|
|
374
|
+
def _restart_wan(self) -> bool:
|
|
375
|
+
status = self.get_wan_status()
|
|
376
|
+
unit = self._read_wan_unit(status) if status else None
|
|
377
|
+
|
|
378
|
+
services_to_try: list[str] = []
|
|
379
|
+
if unit in (0, 1):
|
|
380
|
+
services_to_try.append(f"restart_wan{unit}")
|
|
381
|
+
services_to_try.append("restart_wan")
|
|
382
|
+
|
|
383
|
+
for service in services_to_try:
|
|
384
|
+
if self._run_service(service):
|
|
385
|
+
return True
|
|
386
|
+
|
|
387
|
+
return False
|
|
388
|
+
|
|
389
|
+
def make_pppoe_reconnection(self) -> bool:
|
|
390
|
+
if self._restart_wan():
|
|
391
|
+
return True
|
|
392
|
+
|
|
393
|
+
logger.error("Failed to reconnect pppoe.")
|
|
394
|
+
return False
|
|
395
|
+
|
|
396
|
+
def dhcp_renew(self) -> bool:
|
|
397
|
+
if self._restart_wan():
|
|
398
|
+
return True
|
|
399
|
+
|
|
400
|
+
logger.error("Failed to renew dhcp.")
|
|
401
|
+
return False
|
|
402
|
+
|
|
403
|
+
def get_connected_devices(self) -> list[dict[str, Any]]:
|
|
404
|
+
ok, content = self._post_text_request(
|
|
405
|
+
endpoint="update_clients.asp",
|
|
406
|
+
raw_payload="",
|
|
407
|
+
form_payload={},
|
|
408
|
+
headers=self._auth_headers(),
|
|
409
|
+
)
|
|
410
|
+
if not ok or content is None:
|
|
411
|
+
logger.error("Failed to get ASUS connected devices.")
|
|
412
|
+
return []
|
|
413
|
+
|
|
414
|
+
data = self._read_update_clients_data(content)
|
|
415
|
+
if not data:
|
|
416
|
+
logger.error("Failed to parse ASUS connected devices.")
|
|
417
|
+
return []
|
|
418
|
+
|
|
419
|
+
current_clients = self._read_client_map(data.get("fromNetworkmapd"))
|
|
420
|
+
historical_clients = self._read_client_map(data.get("nmpClient"))
|
|
421
|
+
|
|
422
|
+
devices: list[dict[str, Any]] = []
|
|
423
|
+
for mac, client in current_clients.items():
|
|
424
|
+
if not self._is_mac_address(mac) or not isinstance(client, dict):
|
|
425
|
+
continue
|
|
426
|
+
|
|
427
|
+
fallback = historical_clients.get(mac)
|
|
428
|
+
merged_client = self._merge_client_metadata(
|
|
429
|
+
client,
|
|
430
|
+
fallback if isinstance(fallback, dict) else None,
|
|
431
|
+
)
|
|
432
|
+
if not self._is_online(merged_client):
|
|
433
|
+
continue
|
|
434
|
+
|
|
435
|
+
devices.append(
|
|
436
|
+
{
|
|
437
|
+
"hostname": self._read_device_name(merged_client),
|
|
438
|
+
"ip": merged_client.get("ip", "-") or "-",
|
|
439
|
+
"mac": mac,
|
|
440
|
+
"type": self._read_connection_type(merged_client),
|
|
441
|
+
"is_current": str(merged_client.get("isLogin", "0")).strip() == "1",
|
|
442
|
+
"up_kbps": self._read_speed(merged_client.get("curTx")),
|
|
443
|
+
"down_kbps": self._read_speed(merged_client.get("curRx")),
|
|
444
|
+
}
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
devices.sort(
|
|
448
|
+
key=lambda device: (
|
|
449
|
+
not device["is_current"],
|
|
450
|
+
device["hostname"].lower(),
|
|
451
|
+
device["mac"],
|
|
452
|
+
)
|
|
453
|
+
)
|
|
454
|
+
return devices
|