tplinkrouterc6u 5.10.3__py3-none-any.whl → 5.12.0__py3-none-any.whl
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.
- test/test_client_re330.py +296 -0
- tplinkrouterc6u/__init__.py +3 -2
- tplinkrouterc6u/client/c80.py +7 -28
- tplinkrouterc6u/client/ex.py +38 -1
- tplinkrouterc6u/client/mr.py +76 -5
- tplinkrouterc6u/client/mr200.py +113 -5
- tplinkrouterc6u/client/re330.py +393 -0
- tplinkrouterc6u/client/xdr.py +2 -0
- tplinkrouterc6u/common/dataclass.py +1 -0
- tplinkrouterc6u/common/encryption.py +113 -0
- tplinkrouterc6u/common/package_enum.py +1 -0
- tplinkrouterc6u/provider.py +9 -5
- {tplinkrouterc6u-5.10.3.dist-info → tplinkrouterc6u-5.12.0.dist-info}/METADATA +16 -5
- {tplinkrouterc6u-5.10.3.dist-info → tplinkrouterc6u-5.12.0.dist-info}/RECORD +17 -15
- {tplinkrouterc6u-5.10.3.dist-info → tplinkrouterc6u-5.12.0.dist-info}/WHEEL +0 -0
- {tplinkrouterc6u-5.10.3.dist-info → tplinkrouterc6u-5.12.0.dist-info}/licenses/LICENSE +0 -0
- {tplinkrouterc6u-5.10.3.dist-info → tplinkrouterc6u-5.12.0.dist-info}/top_level.txt +0 -0
tplinkrouterc6u/client/mr200.py
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
import base64
|
|
2
|
-
from tplinkrouterc6u.common.exception import ClientException, AuthorizeError
|
|
3
2
|
from tplinkrouterc6u.client.mr import TPLinkMRClient
|
|
4
3
|
from Crypto.PublicKey import RSA
|
|
5
|
-
from re import search
|
|
6
4
|
from binascii import hexlify
|
|
7
5
|
from Crypto.Cipher import PKCS1_v1_5
|
|
6
|
+
from re import search
|
|
7
|
+
from tplinkrouterc6u.common.package_enum import VPN
|
|
8
|
+
from tplinkrouterc6u.common.dataclass import (
|
|
9
|
+
LTEStatus,
|
|
10
|
+
VPNStatus,
|
|
11
|
+
)
|
|
12
|
+
from tplinkrouterc6u.common.exception import ClientException, ClientError, AuthorizeError
|
|
8
13
|
|
|
9
14
|
|
|
10
15
|
class TPLinkMR200Client(TPLinkMRClient):
|
|
11
16
|
|
|
17
|
+
def supports(self) -> bool:
|
|
18
|
+
try:
|
|
19
|
+
self.__get_params()
|
|
20
|
+
return True
|
|
21
|
+
except ClientException:
|
|
22
|
+
return False
|
|
23
|
+
|
|
12
24
|
def authorize(self) -> None:
|
|
13
25
|
params = self.__get_params()
|
|
14
26
|
|
|
@@ -36,11 +48,76 @@ class TPLinkMR200Client(TPLinkMRClient):
|
|
|
36
48
|
# Try to extract token
|
|
37
49
|
r = self.req.get(self.host)
|
|
38
50
|
try:
|
|
39
|
-
self.
|
|
51
|
+
self.req.headers["TokenID"] = search(r'var token="(.*)";', r.text).group(1)
|
|
40
52
|
except AttributeError:
|
|
41
53
|
raise AuthorizeError()
|
|
42
54
|
|
|
55
|
+
def get_vpn_status(self) -> VPNStatus:
|
|
56
|
+
status = VPNStatus()
|
|
57
|
+
acts = [
|
|
58
|
+
self.ActItem(self.ActItem.GL, 'IPSEC_CFG'),
|
|
59
|
+
]
|
|
60
|
+
_, values = self.req_act(acts)
|
|
61
|
+
|
|
62
|
+
status.ipsecvpn_enable = values.get('enable') == '1'
|
|
63
|
+
|
|
64
|
+
return status
|
|
65
|
+
|
|
66
|
+
def set_vpn(self, vpn: VPN, enable: bool) -> None:
|
|
67
|
+
acts = [
|
|
68
|
+
self.ActItem(
|
|
69
|
+
self.ActItem.SET,
|
|
70
|
+
'IPSEC_CFG',
|
|
71
|
+
'1,0,0,0,0,0',
|
|
72
|
+
attrs=['enable={}'.format(int(enable))]
|
|
73
|
+
)
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
self.req_act(acts)
|
|
77
|
+
|
|
78
|
+
def logout(self) -> None:
|
|
79
|
+
acts = [
|
|
80
|
+
self.ActItem(self.ActItem.CGI, '/cgi/logout')
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
response, _ = self.req_act(acts)
|
|
84
|
+
ret_code = self._parse_ret_val(response)
|
|
85
|
+
|
|
86
|
+
if ret_code == self.HTTP_RET_OK:
|
|
87
|
+
del self.req.headers["TokenID"]
|
|
88
|
+
|
|
89
|
+
def get_lte_status(self) -> LTEStatus:
|
|
90
|
+
status = LTEStatus()
|
|
91
|
+
acts = [
|
|
92
|
+
self.ActItem(self.ActItem.GET, 'WAN_LTE_LINK_CFG', '2,1,0,0,0,0',
|
|
93
|
+
attrs=['enable', 'connectStatus', 'networkType', 'roamingStatus', 'simStatus']),
|
|
94
|
+
self.ActItem(self.ActItem.GET, 'WAN_LTE_INTF_CFG', '2,0,0,0,0,0',
|
|
95
|
+
attrs=['dataLimit', 'enablePaymentDay', 'curStatistics', 'totalStatistics', 'enableDataLimit',
|
|
96
|
+
'limitation',
|
|
97
|
+
'curRxSpeed', 'curTxSpeed']),
|
|
98
|
+
self.ActItem(self.ActItem.GET, 'LTE_WAN_CFG', '2,1,0,0,0,0'),
|
|
99
|
+
]
|
|
100
|
+
_, values = self.req_act(acts)
|
|
101
|
+
|
|
102
|
+
status.enable = values['0'].get('enable', 0)
|
|
103
|
+
status.connect_status = values['0'].get('connectStatus', 0)
|
|
104
|
+
status.network_type = values['0'].get('networkType', 0)
|
|
105
|
+
status.sim_status = values['0'].get('simStatus', 0)
|
|
106
|
+
status.sig_level = values['0'].get('signalStrength', 0)
|
|
107
|
+
|
|
108
|
+
status.total_statistics = values['1'].get('totalStatistics', 0)
|
|
109
|
+
status.cur_rx_speed = values['1'].get('curRxSpeed', 0)
|
|
110
|
+
status.cur_tx_speed = values['1'].get('curTxSpeed', 0)
|
|
111
|
+
|
|
112
|
+
status.isp_name = values['2'].get('profileName', '')
|
|
113
|
+
|
|
114
|
+
sms_list = self.get_sms()
|
|
115
|
+
status.sms_unread_count = sum(1 for m in sms_list if getattr(m, 'unread', False))
|
|
116
|
+
|
|
117
|
+
return status
|
|
118
|
+
|
|
43
119
|
def __get_params(self, retry=False):
|
|
120
|
+
self.req.headers = {'referer': f'{self.host}/', 'origin': self.host}
|
|
44
121
|
try:
|
|
45
122
|
r = self.req.get(f"{self.host}/cgi/getParm", timeout=5)
|
|
46
123
|
result = {}
|
|
@@ -53,5 +130,36 @@ class TPLinkMR200Client(TPLinkMRClient):
|
|
|
53
130
|
return self.__get_params(True)
|
|
54
131
|
raise ClientException()
|
|
55
132
|
|
|
56
|
-
def
|
|
57
|
-
|
|
133
|
+
def req_act(self, acts: list):
|
|
134
|
+
'''
|
|
135
|
+
Requests ACTs via the cgi_gdpr proxy
|
|
136
|
+
'''
|
|
137
|
+
act_types = []
|
|
138
|
+
act_data = []
|
|
139
|
+
|
|
140
|
+
for act in acts:
|
|
141
|
+
act_types.append(str(act.type))
|
|
142
|
+
act_data.append('[{}#{}#{}]{},{}\r\n{}\r\n'.format(
|
|
143
|
+
act.oid,
|
|
144
|
+
act.stack,
|
|
145
|
+
act.pstack,
|
|
146
|
+
len(act_types) - 1, # index, starts at 0
|
|
147
|
+
len(act.attrs),
|
|
148
|
+
'\r\n'.join(act.attrs)
|
|
149
|
+
))
|
|
150
|
+
|
|
151
|
+
data = ''.join(act_data)
|
|
152
|
+
url = f"{self.host}/cgi?" + '&'.join(act_types)
|
|
153
|
+
response = self.req.post(url, data=data)
|
|
154
|
+
code = response.status_code
|
|
155
|
+
|
|
156
|
+
if code != 200:
|
|
157
|
+
error = 'TplinkRouter - MR200 - Response with error; Request {} - Response {}'.format(data, response.text)
|
|
158
|
+
if self._logger:
|
|
159
|
+
self._logger.debug(error)
|
|
160
|
+
raise ClientError(error)
|
|
161
|
+
|
|
162
|
+
# Convert Response to string for _merge_response
|
|
163
|
+
result = self._merge_response(response.text)
|
|
164
|
+
|
|
165
|
+
return response, result.get('0') if len(result) == 1 and result.get('0') else result
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from logging import Logger
|
|
3
|
+
from urllib import parse
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from ipaddress import IPv4Address
|
|
6
|
+
import re
|
|
7
|
+
from macaddress import EUI48
|
|
8
|
+
import requests
|
|
9
|
+
from requests import Session
|
|
10
|
+
from tplinkrouterc6u.common.package_enum import Connection
|
|
11
|
+
from tplinkrouterc6u.common.exception import ClientException
|
|
12
|
+
from tplinkrouterc6u.common.encryption import EncryptionWrapper
|
|
13
|
+
from tplinkrouterc6u.common.dataclass import Firmware, Status, IPv4Status, IPv4Reservation
|
|
14
|
+
from tplinkrouterc6u.common.dataclass import IPv4DHCPLease, Device
|
|
15
|
+
from tplinkrouterc6u.client_abstract import AbstractRouter
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RouterConstants:
|
|
19
|
+
AUTH_TOKEN_INDEX1 = 3
|
|
20
|
+
AUTH_TOKEN_INDEX2 = 4
|
|
21
|
+
|
|
22
|
+
HOST_WIFI_2G_REQUEST = '33|1,1,0'
|
|
23
|
+
HOST_WIFI_5G_REQUEST = '33|2,1,0'
|
|
24
|
+
|
|
25
|
+
CONNECTION_REQUESTS_MAP = {
|
|
26
|
+
Connection.HOST_2G: HOST_WIFI_2G_REQUEST,
|
|
27
|
+
Connection.HOST_5G: HOST_WIFI_5G_REQUEST,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
CONNECTION_TYPE_MAP = {
|
|
31
|
+
'0': 'Dynamic IP',
|
|
32
|
+
'1': 'Static IP',
|
|
33
|
+
'2': 'PPPoE',
|
|
34
|
+
'3': 'L2TP',
|
|
35
|
+
'4': 'PPTP'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RouterConfig:
|
|
40
|
+
"""Configuration parameters for the router."""
|
|
41
|
+
ENCODING: str = ("yLwVl0zKqws7LgKPRQ84Mdt708T1qQ3Ha7xv3H7NyU84p21BriUWBU43odz3iP4rBL3cD02KZciXTysVXiV8"
|
|
42
|
+
"ngg6vL48rPJyAUw0HurW20xqxv9aYb4M9wK1Ae0wlro510qXeU07kV57fQMc8L6aLgMLwygtc0F10a0Dg70T"
|
|
43
|
+
"OoouyFhdysuRMO51yY5ZlOZZLEal1h0t9YQW0Ko7oBwmCAHoic4HYbUyVeU3sfQ1xtXcPcf1aT303wAQhv66qzW")
|
|
44
|
+
KEY: str = "RDpbLfCPsJZ7fiv"
|
|
45
|
+
PAD_CHAR: str = chr(187)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class EncryptionState:
|
|
50
|
+
"""Holds encryption-related state."""
|
|
51
|
+
|
|
52
|
+
def __init__(self):
|
|
53
|
+
self.nn_rsa = ''
|
|
54
|
+
self.ee_rsa = ''
|
|
55
|
+
self.seq = ''
|
|
56
|
+
self.aes = EncryptionWrapper()
|
|
57
|
+
self.token = ''
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Note: This router doesn't support VPN and up/down speeds per device
|
|
61
|
+
class TplinkRE330Router(AbstractRouter):
|
|
62
|
+
DATA_REGEX = re.compile(r'id (\d+\|\d,\d,\d)\r\n(.*?)(?=\r\nid \d+\||$)', re.DOTALL)
|
|
63
|
+
|
|
64
|
+
def __init__(self, host: str, password: str, username: str = 'admin', logger: Logger = None,
|
|
65
|
+
verify_ssl: bool = True, timeout: int = 30) -> None:
|
|
66
|
+
super().__init__(host, password, username, logger, verify_ssl, timeout)
|
|
67
|
+
self._session = Session()
|
|
68
|
+
if self._verify_ssl is False:
|
|
69
|
+
self._session.verify = False
|
|
70
|
+
self._encryption = EncryptionState()
|
|
71
|
+
self.host = self.host.rstrip('/')
|
|
72
|
+
# Mandatory Referer header
|
|
73
|
+
self._headers = {
|
|
74
|
+
'Referer': self.host + '/'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
def _init_session(self) -> None:
|
|
78
|
+
self._session.get(self.host + "/", headers=self._headers)
|
|
79
|
+
|
|
80
|
+
def supports(self) -> bool:
|
|
81
|
+
try:
|
|
82
|
+
response = self.request(2, 0, data='50|1,0,0')
|
|
83
|
+
return response.status_code == 200 and response.text.startswith('00000')
|
|
84
|
+
except Exception:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
def authorize(self) -> None:
|
|
88
|
+
# Init session and connexion
|
|
89
|
+
self._init_session()
|
|
90
|
+
encoded_password = TplinkRE330Router._encrypt_password(self.password)
|
|
91
|
+
|
|
92
|
+
# Get token encryption strings and encrypt the password
|
|
93
|
+
response = self.request(7, 1)
|
|
94
|
+
self._encryption.token = TplinkRE330Router._encode_token(encoded_password, response)
|
|
95
|
+
|
|
96
|
+
# Get RSA exponent, modulus and sequence number
|
|
97
|
+
response = self.request(16, 0, data='get')
|
|
98
|
+
|
|
99
|
+
responseText = response.text.splitlines()
|
|
100
|
+
if len(responseText) < 4:
|
|
101
|
+
raise ClientException("Invalid response for RSA keys from router")
|
|
102
|
+
self._encryption.ee_rsa = responseText[1]
|
|
103
|
+
self._encryption.nn_rsa = responseText[2]
|
|
104
|
+
self._encryption.seq = responseText[3]
|
|
105
|
+
|
|
106
|
+
# Encrypt AES string
|
|
107
|
+
aes_string_encrypted = EncryptionWrapper.rsa_encrypt(self._encryption.aes._get_aes_string(),
|
|
108
|
+
self._encryption.nn_rsa,
|
|
109
|
+
self._encryption.ee_rsa)
|
|
110
|
+
# Mandatory intermediate request
|
|
111
|
+
response = self.request(7, 0, True)
|
|
112
|
+
# Register AES string for decryption on server side
|
|
113
|
+
self.request(16, 0, True, data=f'set {aes_string_encrypted}')
|
|
114
|
+
|
|
115
|
+
def logout(self) -> None:
|
|
116
|
+
self.request(11, 0, True)
|
|
117
|
+
|
|
118
|
+
def get_firmware(self) -> Firmware:
|
|
119
|
+
text = '0|1,0,0'
|
|
120
|
+
|
|
121
|
+
body = self._encrypt_body(text)
|
|
122
|
+
response = self.request(2, 1, True, data=body)
|
|
123
|
+
response_text = self._decrypt_data(response.text)
|
|
124
|
+
device_datamap = dict(line.split(" ", 1) for line in response_text.split("\r\n")[1:-1])
|
|
125
|
+
|
|
126
|
+
return Firmware(parse.unquote(device_datamap['hardVer']), parse.unquote(device_datamap['modelName']),
|
|
127
|
+
parse.unquote(device_datamap['softVer']))
|
|
128
|
+
|
|
129
|
+
def get_status(self) -> Status:
|
|
130
|
+
mac_info_request = "1|1,0,0"
|
|
131
|
+
lan_ip_request = "4|1,0,0"
|
|
132
|
+
wan_ip_request = "23|1,0,0"
|
|
133
|
+
device_data_request = '13|1,0,0'
|
|
134
|
+
all_requests = [
|
|
135
|
+
mac_info_request, lan_ip_request, wan_ip_request, device_data_request,
|
|
136
|
+
RouterConstants.HOST_WIFI_2G_REQUEST, RouterConstants.HOST_WIFI_5G_REQUEST
|
|
137
|
+
]
|
|
138
|
+
request_text = '#'.join(all_requests)
|
|
139
|
+
body = self._encrypt_body(request_text)
|
|
140
|
+
|
|
141
|
+
response = self.request(2, 1, True, data=body)
|
|
142
|
+
response_text = self._decrypt_data(response.text)
|
|
143
|
+
|
|
144
|
+
matches = TplinkRE330Router.DATA_REGEX.findall(response_text)
|
|
145
|
+
|
|
146
|
+
data_blocks = {match[0]: match[1].strip().split("\r\n") for match in matches}
|
|
147
|
+
|
|
148
|
+
def extract_value(response_list, prefix):
|
|
149
|
+
return next((s.split(prefix, 1)[1] for s in response_list if s.startswith(prefix)), None)
|
|
150
|
+
|
|
151
|
+
network_info = {
|
|
152
|
+
'lan_mac': extract_value(data_blocks[mac_info_request], "mac 0 "),
|
|
153
|
+
'wan_mac': extract_value(data_blocks[mac_info_request], "mac 1 "),
|
|
154
|
+
'lan_ip': extract_value(data_blocks[lan_ip_request], "ip "),
|
|
155
|
+
'wan_ip': extract_value(data_blocks[wan_ip_request], "ip "),
|
|
156
|
+
'gateway_ip': extract_value(data_blocks[wan_ip_request], "gateway "),
|
|
157
|
+
'uptime': extract_value(data_blocks[wan_ip_request], "upTime ")
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
wifi_status = {}
|
|
161
|
+
for key, request in RouterConstants.CONNECTION_REQUESTS_MAP.items():
|
|
162
|
+
value = data_blocks.get(request)
|
|
163
|
+
wifi_status[key] = extract_value(data_blocks.get(request), "bEnable ") == '1' if value else None
|
|
164
|
+
|
|
165
|
+
device_data_response = data_blocks[device_data_request]
|
|
166
|
+
|
|
167
|
+
mapped_devices = self._parse_devices(device_data_response)
|
|
168
|
+
|
|
169
|
+
status = Status()
|
|
170
|
+
status._wan_macaddr = EUI48(network_info['wan_mac'])
|
|
171
|
+
status._lan_macaddr = EUI48(network_info['lan_mac'])
|
|
172
|
+
status._lan_ipv4_addr = IPv4Address(network_info['lan_ip'])
|
|
173
|
+
status._wan_ipv4_addr = IPv4Address(network_info['wan_ip'])
|
|
174
|
+
status._wan_ipv4_gateway = IPv4Address(network_info['gateway_ip'])
|
|
175
|
+
status.wan_ipv4_uptime = int(network_info['uptime']) // 100
|
|
176
|
+
|
|
177
|
+
status.wifi_2g_enable = wifi_status[Connection.HOST_2G]
|
|
178
|
+
status.wifi_5g_enable = wifi_status[Connection.HOST_5G]
|
|
179
|
+
|
|
180
|
+
status.wired_total = sum(1 for device in mapped_devices if device.type == Connection.WIRED)
|
|
181
|
+
status.wifi_clients_total = sum(1 for device in mapped_devices
|
|
182
|
+
if device.type in (Connection.HOST_2G, Connection.HOST_5G))
|
|
183
|
+
status.guest_clients_total = sum(1 for device in mapped_devices
|
|
184
|
+
if device.type in (Connection.GUEST_2G, Connection.GUEST_5G))
|
|
185
|
+
status.iot_clients_total = sum(1 for device in mapped_devices
|
|
186
|
+
if device.type in (Connection.IOT_2G, Connection.IOT_5G))
|
|
187
|
+
status.clients_total = (status.wired_total + status.wifi_clients_total +
|
|
188
|
+
status.guest_clients_total + status.iot_clients_total)
|
|
189
|
+
|
|
190
|
+
status.devices = mapped_devices
|
|
191
|
+
return status
|
|
192
|
+
|
|
193
|
+
def set_led_status(self, status: bool) -> None:
|
|
194
|
+
text = f'id 112|1,0,0\r\nenable {1 if status else 0}\r\n'
|
|
195
|
+
body = self._encrypt_body(text)
|
|
196
|
+
self.request(1, 0, True, data=body)
|
|
197
|
+
|
|
198
|
+
def get_led_status(self) -> bool:
|
|
199
|
+
text = '112|1,0,0'
|
|
200
|
+
body = self._encrypt_body(text)
|
|
201
|
+
response = self.request(2, 0, True, data=body)
|
|
202
|
+
|
|
203
|
+
response_text = self._decrypt_data(response.text)
|
|
204
|
+
response_text = response_text.splitlines()
|
|
205
|
+
if len(response_text) < 3:
|
|
206
|
+
raise ClientException("Invalid response for LED status from router")
|
|
207
|
+
|
|
208
|
+
return response_text[2][-1:] == '1'
|
|
209
|
+
|
|
210
|
+
def reboot(self) -> None:
|
|
211
|
+
self.request(6, 1, True)
|
|
212
|
+
|
|
213
|
+
def set_wifi(self, wifi: Connection, enable: bool) -> None:
|
|
214
|
+
enable_string = f'bEnable {int(enable)}'
|
|
215
|
+
text = f'id {RouterConstants.CONNECTION_REQUESTS_MAP[wifi]}\r\n{enable_string}'
|
|
216
|
+
body = self._encrypt_body(text)
|
|
217
|
+
self.request(1, 0, True, data=body)
|
|
218
|
+
|
|
219
|
+
def get_ipv4_status(self) -> IPv4Status:
|
|
220
|
+
mac_info_request = "1|1,0,0"
|
|
221
|
+
lan_ip_request = "4|1,0,0"
|
|
222
|
+
dhcp_request = "8|1,0,0"
|
|
223
|
+
link_type_request = "22|1,0,0"
|
|
224
|
+
wan_ip_request = "23|1,0,0"
|
|
225
|
+
static_ip_request = "24|1,0,0"
|
|
226
|
+
all_requests = [
|
|
227
|
+
mac_info_request, lan_ip_request, dhcp_request, link_type_request, wan_ip_request, static_ip_request]
|
|
228
|
+
request_text = '#'.join(all_requests)
|
|
229
|
+
body = self._encrypt_body(request_text)
|
|
230
|
+
|
|
231
|
+
response = self.request(2, 1, True, data=body)
|
|
232
|
+
response_text = self._decrypt_data(response.text)
|
|
233
|
+
|
|
234
|
+
matches = TplinkRE330Router.DATA_REGEX.findall(response_text)
|
|
235
|
+
|
|
236
|
+
data_blocks = {match[0]: match[1].strip().split("\r\n") for match in matches}
|
|
237
|
+
|
|
238
|
+
network_info = {
|
|
239
|
+
'lan_mac': self._extract_value(data_blocks[mac_info_request], "mac 0 "),
|
|
240
|
+
'wan_mac': self._extract_value(data_blocks[mac_info_request], "mac 1 "),
|
|
241
|
+
'lan_ip': self._extract_value(data_blocks[lan_ip_request], "ip "),
|
|
242
|
+
'wan_ip': self._extract_value(data_blocks[wan_ip_request], "ip "),
|
|
243
|
+
'gateway_ip': self._extract_value(data_blocks[wan_ip_request], "gateway "),
|
|
244
|
+
'uptime': self._extract_value(data_blocks[wan_ip_request], "upTime "),
|
|
245
|
+
'wan_mask': self._extract_value(data_blocks[wan_ip_request], "mask "),
|
|
246
|
+
'lan_mask': self._extract_value(data_blocks[lan_ip_request], "mask "),
|
|
247
|
+
'dns_1': self._extract_value(data_blocks[wan_ip_request], "dns 0 "),
|
|
248
|
+
'dns_2': self._extract_value(data_blocks[wan_ip_request], "dns 1 "),
|
|
249
|
+
'dhcp_enabled': self._extract_value(data_blocks[dhcp_request], "enable "),
|
|
250
|
+
'link_type': self._extract_value(data_blocks[link_type_request], "linkType "),
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
ipv4status = IPv4Status()
|
|
254
|
+
ipv4status._wan_macaddr = EUI48(network_info['wan_mac'])
|
|
255
|
+
ipv4status._wan_ipv4_ipaddr = IPv4Address(network_info['wan_ip'])
|
|
256
|
+
ipv4status._wan_ipv4_gateway = IPv4Address(network_info['gateway_ip'])
|
|
257
|
+
ipv4status._wan_ipv4_conntype = RouterConstants.CONNECTION_TYPE_MAP[network_info['link_type']]
|
|
258
|
+
ipv4status._wan_ipv4_netmask = IPv4Address(network_info['wan_mask'])
|
|
259
|
+
ipv4status._wan_ipv4_pridns = IPv4Address(network_info['dns_1'])
|
|
260
|
+
ipv4status._wan_ipv4_snddns = IPv4Address(network_info['dns_2'])
|
|
261
|
+
ipv4status._lan_macaddr = EUI48(network_info['lan_mac'])
|
|
262
|
+
ipv4status._lan_ipv4_ipaddr = IPv4Address(network_info['lan_ip'])
|
|
263
|
+
ipv4status.lan_ipv4_dhcp_enable = network_info['dhcp_enabled'] == '1'
|
|
264
|
+
ipv4status._lan_ipv4_netmask = IPv4Address(network_info['lan_mask'])
|
|
265
|
+
return ipv4status
|
|
266
|
+
|
|
267
|
+
def get_ipv4_reservations(self) -> list[IPv4Reservation]:
|
|
268
|
+
body = self._encrypt_body('12|1,0,0')
|
|
269
|
+
|
|
270
|
+
response = self.request(2, 1, True, data=body)
|
|
271
|
+
response_text = self._decrypt_data(response.text)
|
|
272
|
+
matches = TplinkRE330Router.DATA_REGEX.findall(response_text)
|
|
273
|
+
|
|
274
|
+
data_blocks = {match[0]: match[1].strip().split("\r\n") for match in matches}
|
|
275
|
+
filtered_reservations = self._parse_response_to_dict(data_blocks['12|1,0,0'])
|
|
276
|
+
|
|
277
|
+
mapped_reservations: list[IPv4Reservation] = []
|
|
278
|
+
for reservation in filtered_reservations:
|
|
279
|
+
reservation_to_add = IPv4Reservation(EUI48(reservation['mac']), IPv4Address(reservation['ip']),
|
|
280
|
+
reservation['name'], reservation['dhcpsEnable'] == '1')
|
|
281
|
+
mapped_reservations.append(reservation_to_add)
|
|
282
|
+
return mapped_reservations
|
|
283
|
+
|
|
284
|
+
def get_dhcp_leases(self) -> list[IPv4DHCPLease]:
|
|
285
|
+
body = self._encrypt_body('9|1,0,0')
|
|
286
|
+
|
|
287
|
+
response = self.request(2, 1, True, data=body)
|
|
288
|
+
response_text = self._decrypt_data(response.text)
|
|
289
|
+
matches = TplinkRE330Router.DATA_REGEX.findall(response_text)
|
|
290
|
+
|
|
291
|
+
data_blocks = {match[0]: match[1].strip().split("\r\n") for match in matches}
|
|
292
|
+
|
|
293
|
+
filtered_leases = self._parse_response_to_dict(data_blocks['9|1,0,0'])
|
|
294
|
+
|
|
295
|
+
mapped_leases: list[IPv4DHCPLease] = []
|
|
296
|
+
for lease in filtered_leases:
|
|
297
|
+
lease_to_add = IPv4DHCPLease(EUI48(lease['mac']), IPv4Address(lease['ip']),
|
|
298
|
+
lease['hostName'], f'expires {lease["expires"]}')
|
|
299
|
+
mapped_leases.append(lease_to_add)
|
|
300
|
+
|
|
301
|
+
return mapped_leases
|
|
302
|
+
|
|
303
|
+
def _parse_devices(self, device_data_response: list[str]) -> list[Device]:
|
|
304
|
+
filtered_devices = self._parse_response_to_dict(device_data_response)
|
|
305
|
+
|
|
306
|
+
device_type_to_connection = {
|
|
307
|
+
0: Connection.WIRED,
|
|
308
|
+
1: Connection.HOST_2G, 2: Connection.GUEST_2G,
|
|
309
|
+
3: Connection.HOST_5G, 4: Connection.GUEST_5G,
|
|
310
|
+
13: Connection.IOT_2G, 14: Connection.IOT_5G
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
mapped_devices = []
|
|
314
|
+
for device in filtered_devices:
|
|
315
|
+
if device['online'] == '1':
|
|
316
|
+
device_type = int(device['type'])
|
|
317
|
+
connection_type = device_type_to_connection.get(device_type, Connection.UNKNOWN)
|
|
318
|
+
else:
|
|
319
|
+
connection_type = Connection.UNKNOWN
|
|
320
|
+
|
|
321
|
+
device_to_add = Device(connection_type, EUI48(device['mac']), IPv4Address(device['ip']), device['name'])
|
|
322
|
+
device_to_add.up_speed = 0 # Not supported by the router
|
|
323
|
+
device_to_add.down_speed = 0 # Not supported by the router
|
|
324
|
+
device_to_add.active = device['online'] == '1'
|
|
325
|
+
mapped_devices.append(device_to_add)
|
|
326
|
+
return mapped_devices
|
|
327
|
+
|
|
328
|
+
def _parse_response_to_dict(self, response_data: list[str]) -> list[dict]:
|
|
329
|
+
result_dict = defaultdict(dict)
|
|
330
|
+
for entry in response_data:
|
|
331
|
+
parts = entry.split(' ', 2)
|
|
332
|
+
key, id_str = parts[0], parts[1]
|
|
333
|
+
value = parts[2] if len(parts) == 3 else ''
|
|
334
|
+
result_dict[int(id_str)][key] = value
|
|
335
|
+
|
|
336
|
+
return [v for _, v in result_dict.items() if v.get("ip") != "0.0.0.0"]
|
|
337
|
+
|
|
338
|
+
@staticmethod
|
|
339
|
+
def _encrypt_password(pwd: str, key: str = RouterConfig.KEY, encoding: str = RouterConfig.ENCODING) -> str:
|
|
340
|
+
max_len = max(len(key), len(pwd))
|
|
341
|
+
pwd = pwd.ljust(max_len, RouterConfig.PAD_CHAR)
|
|
342
|
+
key = key.ljust(max_len, RouterConfig.PAD_CHAR)
|
|
343
|
+
|
|
344
|
+
result = []
|
|
345
|
+
for i in range(max_len):
|
|
346
|
+
result.append(encoding[(ord(pwd[i]) ^ ord(key[i])) % len(encoding)])
|
|
347
|
+
|
|
348
|
+
return "".join(result)
|
|
349
|
+
|
|
350
|
+
@staticmethod
|
|
351
|
+
def _encode_token(encoded_password: str, response: requests.Response) -> str:
|
|
352
|
+
response_text = response.text.splitlines()
|
|
353
|
+
auth_info1 = response_text[RouterConstants.AUTH_TOKEN_INDEX1]
|
|
354
|
+
auth_info2 = response_text[RouterConstants.AUTH_TOKEN_INDEX2]
|
|
355
|
+
|
|
356
|
+
encoded_token = TplinkRE330Router._encrypt_password(encoded_password, auth_info1, auth_info2)
|
|
357
|
+
return parse.quote(encoded_token, safe='!()*')
|
|
358
|
+
|
|
359
|
+
def _get_signature(self, datalen: int) -> str:
|
|
360
|
+
encryption = self._encryption
|
|
361
|
+
r = f'{encryption.aes._get_aes_string()}&s={str(int(encryption.seq) + datalen)}'
|
|
362
|
+
e = ''
|
|
363
|
+
n = 0
|
|
364
|
+
while n < len(r):
|
|
365
|
+
e += EncryptionWrapper.rsa_encrypt(r[n:53], encryption.nn_rsa, encryption.ee_rsa)
|
|
366
|
+
n += 53
|
|
367
|
+
return e
|
|
368
|
+
|
|
369
|
+
def _encrypt_body(self, text: str) -> str:
|
|
370
|
+
data = self._encryption.aes.aes_encrypt(text)
|
|
371
|
+
sign = self._get_signature(len(data))
|
|
372
|
+
return f'sign={sign}\r\ndata={data}'
|
|
373
|
+
|
|
374
|
+
def _decrypt_data(self, encrypted_text: str) -> str:
|
|
375
|
+
return self._encryption.aes.aes_decrypt(encrypted_text)
|
|
376
|
+
|
|
377
|
+
def _extract_value(self, response_list, prefix):
|
|
378
|
+
return next((s.split(prefix, 1)[1] for s in response_list if s.startswith(prefix)), None)
|
|
379
|
+
|
|
380
|
+
def request(self, code: int, asyn: int, use_token: bool = False, data: str = None):
|
|
381
|
+
url = f"{self.host}/?code={code}&asyn={asyn}"
|
|
382
|
+
if use_token:
|
|
383
|
+
url += f"&id={self._encryption.token}"
|
|
384
|
+
try:
|
|
385
|
+
response = self._session.post(url, data=data, timeout=self.timeout,
|
|
386
|
+
verify=self._verify_ssl, headers=self._headers)
|
|
387
|
+
# Raises exception for 4XX/5XX status codes for all requests except 1st in authorize
|
|
388
|
+
if not (code == 7 and asyn == 1 and use_token is False and data is None):
|
|
389
|
+
response.raise_for_status()
|
|
390
|
+
return response
|
|
391
|
+
except requests.exceptions.RequestException as e:
|
|
392
|
+
self._logger.error(f"Network error: {e}")
|
|
393
|
+
raise ClientException(f"Network error: {str(e)}") from e
|
tplinkrouterc6u/client/xdr.py
CHANGED
|
@@ -116,6 +116,8 @@ class TPLinkXDRClient(AbstractRouter):
|
|
|
116
116
|
dev = Device(conn_type, get_mac(item['mac']), get_ip(item['ip']), unquote(item['hostname']))
|
|
117
117
|
dev.up_speed = item['up_speed']
|
|
118
118
|
dev.down_speed = item['down_speed']
|
|
119
|
+
if 'online' in item:
|
|
120
|
+
dev.active = item['online'] == '1'
|
|
119
121
|
status.devices.append(dev)
|
|
120
122
|
return status
|
|
121
123
|
|
|
@@ -189,3 +189,116 @@ class EncryptionWrapperMR:
|
|
|
189
189
|
n = int('0x' + nn, 16)
|
|
190
190
|
e = int('0x' + ee, 16)
|
|
191
191
|
return RSA.construct((n, e))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class EncryptionWrapperMRGCM:
|
|
195
|
+
RSA_USE_PKCS_V1_5 = False
|
|
196
|
+
AES_KEY_LEN = 128 // 8
|
|
197
|
+
AES_IV_LEN = 12 # previously 16
|
|
198
|
+
|
|
199
|
+
def __init__(self) -> None:
|
|
200
|
+
ts = str(round(time() * 1000))
|
|
201
|
+
|
|
202
|
+
key = (ts + str(randint(100000000, 1000000000 - 1)))[:self.AES_KEY_LEN]
|
|
203
|
+
iv = (ts + str(randint(100000000, 1000000000 - 1)))[:self.AES_IV_LEN]
|
|
204
|
+
|
|
205
|
+
assert len(key) == self.AES_KEY_LEN
|
|
206
|
+
assert len(iv) == self.AES_IV_LEN
|
|
207
|
+
|
|
208
|
+
self._key = key
|
|
209
|
+
self._iv = iv
|
|
210
|
+
|
|
211
|
+
def aes_encrypt(self, raw: str) -> tuple[str, str]:
|
|
212
|
+
# not sure what the padding was for here:
|
|
213
|
+
# data_padded = pad(raw.encode('utf8'), 16, 'pkcs7')
|
|
214
|
+
|
|
215
|
+
# encrypt the body
|
|
216
|
+
aes_encryptor = self._make_aes_cipher()
|
|
217
|
+
encrypted_data_bytes, tag = aes_encryptor.encrypt_and_digest(raw.encode('utf-8'))
|
|
218
|
+
return (
|
|
219
|
+
b64encode(encrypted_data_bytes).decode(), # router expects b64 ciphertext
|
|
220
|
+
b64encode(tag).decode() # router expects b64 tag (this is new)
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def aes_decrypt(self, data: str):
|
|
224
|
+
# decode base64 string
|
|
225
|
+
tag = b64decode(data[-24:]) # last 24 characters are the tag
|
|
226
|
+
encrypted_response_data = b64decode(data[:-24])
|
|
227
|
+
# decrypt the response using our AES key
|
|
228
|
+
aes_decryptor = self._make_aes_cipher()
|
|
229
|
+
response = aes_decryptor.decrypt_and_verify(encrypted_response_data, tag)
|
|
230
|
+
|
|
231
|
+
# not sure what unpad did here before:
|
|
232
|
+
# return unpad(response, 16, 'pkcs7').decode('utf8')
|
|
233
|
+
return response.decode('utf8')
|
|
234
|
+
|
|
235
|
+
def get_signature(self, seq: int, is_login: bool, hash: str, nn: str, ee: str) -> str:
|
|
236
|
+
if is_login:
|
|
237
|
+
# on login we also send our AES key, which is subsequently
|
|
238
|
+
# used for E2E encrypted communication
|
|
239
|
+
# key and iv now base64 not like before
|
|
240
|
+
key = b64encode(self._key.encode('utf-8')).decode()
|
|
241
|
+
iv = b64encode(self._iv.encode('utf-8')).decode()
|
|
242
|
+
sign_data = 'key={}&iv={}&h={}&s={}'.format(key, iv, hash, seq)
|
|
243
|
+
else:
|
|
244
|
+
sign_data = 'h={}&s={}'.format(hash, seq)
|
|
245
|
+
|
|
246
|
+
# set step based on whether PKCS padding is used
|
|
247
|
+
rsa_byte_len = len(nn) // 2 # hexlen / 2 * 8 / 8
|
|
248
|
+
step = (rsa_byte_len - 11) if self.RSA_USE_PKCS_V1_5 else rsa_byte_len
|
|
249
|
+
|
|
250
|
+
# encrypt the signature using the RSA public key
|
|
251
|
+
rsa_key = self._make_rsa_pub_key(nn, ee)
|
|
252
|
+
|
|
253
|
+
# make the PKCS#1 v1.5 cipher
|
|
254
|
+
if self.RSA_USE_PKCS_V1_5:
|
|
255
|
+
rsa = PKCS1_v1_5.new(rsa_key)
|
|
256
|
+
|
|
257
|
+
signature = ''
|
|
258
|
+
pos = 0
|
|
259
|
+
|
|
260
|
+
while pos < len(sign_data):
|
|
261
|
+
sign_data_bin = sign_data[pos: pos + step].encode('utf8')
|
|
262
|
+
|
|
263
|
+
if self.RSA_USE_PKCS_V1_5:
|
|
264
|
+
# encrypt using the PKCS#1 v1.5 padding
|
|
265
|
+
enc = rsa.encrypt(sign_data_bin)
|
|
266
|
+
else:
|
|
267
|
+
# encrypt using NOPADDING
|
|
268
|
+
# ... pad the end with zero bytes
|
|
269
|
+
while len(sign_data_bin) < step:
|
|
270
|
+
sign_data_bin = sign_data_bin + b'\0'
|
|
271
|
+
|
|
272
|
+
# step 3a (OS2IP)
|
|
273
|
+
em_int = bytes_to_long(sign_data_bin)
|
|
274
|
+
|
|
275
|
+
# step 3b (RSAEP)
|
|
276
|
+
m_int = rsa_key._encrypt(em_int)
|
|
277
|
+
|
|
278
|
+
# step 3c (I2OSP)
|
|
279
|
+
enc = long_to_bytes(m_int, 1)
|
|
280
|
+
|
|
281
|
+
# hexlify to string
|
|
282
|
+
enc_str = hexlify(enc).decode('utf8')
|
|
283
|
+
|
|
284
|
+
# pad the start with '0' hex char
|
|
285
|
+
while len(enc_str) < rsa_byte_len * 2:
|
|
286
|
+
enc_str = '0' + enc_str
|
|
287
|
+
|
|
288
|
+
signature += enc_str
|
|
289
|
+
pos = pos + step
|
|
290
|
+
|
|
291
|
+
return signature
|
|
292
|
+
|
|
293
|
+
def _make_aes_cipher(self) -> AES:
|
|
294
|
+
# consider renaming _iv to _nonce
|
|
295
|
+
return AES.new(self._key.encode('utf-8'), AES.MODE_GCM, nonce=self._iv.encode('utf-8'))
|
|
296
|
+
|
|
297
|
+
@staticmethod
|
|
298
|
+
def _make_rsa_pub_key(nn: str, ee: str):
|
|
299
|
+
'''
|
|
300
|
+
Makes a new RSA pub key from tuple (n, e)
|
|
301
|
+
'''
|
|
302
|
+
n = int('0x' + nn, 16)
|
|
303
|
+
e = int('0x' + ee, 16)
|
|
304
|
+
return RSA.construct((n, e))
|