tplinkrouterc6u 5.14.0__py3-none-any.whl → 5.15.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_c6u.py +137 -0
- test/test_client_ex.py +71 -0
- test/test_client_sg.py +217 -0
- tplinkrouterc6u/__init__.py +1 -0
- tplinkrouterc6u/client/c6u.py +22 -10
- tplinkrouterc6u/client/ex.py +33 -0
- tplinkrouterc6u/client/mr200.py +2 -1
- tplinkrouterc6u/client/sg.py +285 -0
- tplinkrouterc6u/common/dataclass.py +4 -0
- tplinkrouterc6u/provider.py +2 -0
- {tplinkrouterc6u-5.14.0.dist-info → tplinkrouterc6u-5.15.0.dist-info}/METADATA +14 -7
- {tplinkrouterc6u-5.14.0.dist-info → tplinkrouterc6u-5.15.0.dist-info}/RECORD +15 -13
- {tplinkrouterc6u-5.14.0.dist-info → tplinkrouterc6u-5.15.0.dist-info}/WHEEL +1 -1
- {tplinkrouterc6u-5.14.0.dist-info → tplinkrouterc6u-5.15.0.dist-info}/licenses/LICENSE +0 -0
- {tplinkrouterc6u-5.14.0.dist-info → tplinkrouterc6u-5.15.0.dist-info}/top_level.txt +0 -0
test/test_client_c6u.py
CHANGED
|
@@ -603,6 +603,143 @@ class TestTPLinkClient(TestCase):
|
|
|
603
603
|
self.assertEqual(status.devices[6].packets_sent, 134815)
|
|
604
604
|
self.assertEqual(status.devices[6].packets_received, 2953078)
|
|
605
605
|
|
|
606
|
+
def test_get_status_with_game_accelerator_fallback_values(self) -> None:
|
|
607
|
+
response_status = '''
|
|
608
|
+
{
|
|
609
|
+
"success": true,
|
|
610
|
+
"data": {
|
|
611
|
+
"lan_macaddr": "06:e6:97:9e:23:f5",
|
|
612
|
+
"access_devices_wireless_host": [
|
|
613
|
+
{
|
|
614
|
+
"wire_type": "5G",
|
|
615
|
+
"macaddr": "f4:aa:bb:cc:dd:ee",
|
|
616
|
+
"ipaddr": "192.168.1.110",
|
|
617
|
+
"hostname": "CLIENT1"
|
|
618
|
+
}
|
|
619
|
+
]
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
'''
|
|
623
|
+
response_game_accelerator = '''
|
|
624
|
+
{
|
|
625
|
+
"data": [
|
|
626
|
+
{"mac": "f4:aa:bb:cc:dd:ee", "deviceTag":"5G", "isGuest":false, "ip":"192.168.1.110",
|
|
627
|
+
"deviceName":"CLIENT1", "downSpeed":112458738, "upSpeed":704111,
|
|
628
|
+
"txRate":1441170, "rxRate":1080880, "online_time":34990.64,
|
|
629
|
+
"trafficUsed":19594852347, "signal": -60},
|
|
630
|
+
{"deviceName":"no-mac-device"}
|
|
631
|
+
],
|
|
632
|
+
"timeout": false,
|
|
633
|
+
"success": true
|
|
634
|
+
}
|
|
635
|
+
'''
|
|
636
|
+
response_stats = '''
|
|
637
|
+
{
|
|
638
|
+
"data": [],
|
|
639
|
+
"timeout": false,
|
|
640
|
+
"success": true,
|
|
641
|
+
"operator": "load"
|
|
642
|
+
}
|
|
643
|
+
'''
|
|
644
|
+
|
|
645
|
+
router_class = self.router_class
|
|
646
|
+
game_accelerator_path = self.game_accelerator_path
|
|
647
|
+
|
|
648
|
+
class TPLinkRouterTest(router_class):
|
|
649
|
+
def request(self, path: str, data: str,
|
|
650
|
+
ignore_response: bool = False, ignore_errors: bool = False) -> dict | None:
|
|
651
|
+
if path == 'admin/status?form=all&operation=read':
|
|
652
|
+
return loads(response_status)['data']
|
|
653
|
+
elif path == game_accelerator_path:
|
|
654
|
+
return loads(response_game_accelerator)['data']
|
|
655
|
+
elif path == 'admin/wireless?form=statistics':
|
|
656
|
+
return loads(response_stats)['data']
|
|
657
|
+
raise ClientException()
|
|
658
|
+
|
|
659
|
+
client = TPLinkRouterTest('', '')
|
|
660
|
+
status = client.get_status()
|
|
661
|
+
|
|
662
|
+
self.assertEqual(len(status.devices), 1)
|
|
663
|
+
device = status.devices[0]
|
|
664
|
+
self.assertEqual(device.macaddr, 'F4-AA-BB-CC-DD-EE')
|
|
665
|
+
self.assertEqual(device.ipaddr, '192.168.1.110')
|
|
666
|
+
self.assertEqual(device.hostname, 'CLIENT1')
|
|
667
|
+
self.assertEqual(device.down_speed, 112458738)
|
|
668
|
+
self.assertEqual(device.up_speed, 704111)
|
|
669
|
+
self.assertEqual(device.tx_rate, 1441170)
|
|
670
|
+
self.assertEqual(device.rx_rate, 1080880)
|
|
671
|
+
self.assertEqual(device.online_time, 34990.64)
|
|
672
|
+
self.assertEqual(device.traffic_usage, 19594852347)
|
|
673
|
+
self.assertEqual(device.signal, -60)
|
|
674
|
+
|
|
675
|
+
def test_get_status_with_game_accelerator_alt_keys(self) -> None:
|
|
676
|
+
response_status = '''
|
|
677
|
+
{
|
|
678
|
+
"success": true,
|
|
679
|
+
"data": {
|
|
680
|
+
"lan_macaddr": "06:e6:97:9e:23:f5",
|
|
681
|
+
"access_devices_wireless_host": [
|
|
682
|
+
{
|
|
683
|
+
"wire_type": "5G",
|
|
684
|
+
"macaddr": "aa:bb:cc:dd:ee:ff",
|
|
685
|
+
"ipaddr": "192.168.1.120",
|
|
686
|
+
"hostname": "CLIENT2"
|
|
687
|
+
}
|
|
688
|
+
]
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
'''
|
|
692
|
+
response_game_accelerator = '''
|
|
693
|
+
{
|
|
694
|
+
"data": [
|
|
695
|
+
{"mac": "aa:bb:cc:dd:ee:ff", "deviceTag":"5G", "isGuest":false, "ip":"192.168.1.120",
|
|
696
|
+
"deviceName":"CLIENT2", "downloadSpeed":12345, "uploadSpeed":2345,
|
|
697
|
+
"txrate":300, "rxrate":400, "onlineTime":123.45,
|
|
698
|
+
"trafficUsage":987654321, "signal": -55}
|
|
699
|
+
],
|
|
700
|
+
"timeout": false,
|
|
701
|
+
"success": true
|
|
702
|
+
}
|
|
703
|
+
'''
|
|
704
|
+
response_stats = '''
|
|
705
|
+
{
|
|
706
|
+
"data": [],
|
|
707
|
+
"timeout": false,
|
|
708
|
+
"success": true,
|
|
709
|
+
"operator": "load"
|
|
710
|
+
}
|
|
711
|
+
'''
|
|
712
|
+
|
|
713
|
+
router_class = self.router_class
|
|
714
|
+
game_accelerator_path = self.game_accelerator_path
|
|
715
|
+
|
|
716
|
+
class TPLinkRouterTest(router_class):
|
|
717
|
+
def request(self, path: str, data: str,
|
|
718
|
+
ignore_response: bool = False, ignore_errors: bool = False) -> dict | None:
|
|
719
|
+
if path == 'admin/status?form=all&operation=read':
|
|
720
|
+
return loads(response_status)['data']
|
|
721
|
+
elif path == game_accelerator_path:
|
|
722
|
+
return loads(response_game_accelerator)['data']
|
|
723
|
+
elif path == 'admin/wireless?form=statistics':
|
|
724
|
+
return loads(response_stats)['data']
|
|
725
|
+
raise ClientException()
|
|
726
|
+
|
|
727
|
+
client = TPLinkRouterTest('', '')
|
|
728
|
+
status = client.get_status()
|
|
729
|
+
|
|
730
|
+
self.assertEqual(len(status.devices), 1)
|
|
731
|
+
device = status.devices[0]
|
|
732
|
+
self.assertEqual(device.macaddr, 'AA-BB-CC-DD-EE-FF')
|
|
733
|
+
self.assertEqual(device.ipaddr, '192.168.1.120')
|
|
734
|
+
self.assertEqual(device.hostname, 'CLIENT2')
|
|
735
|
+
self.assertEqual(device.down_speed, 12345)
|
|
736
|
+
self.assertEqual(device.up_speed, 2345)
|
|
737
|
+
self.assertEqual(device.tx_rate, 300)
|
|
738
|
+
self.assertEqual(device.rx_rate, 400)
|
|
739
|
+
self.assertEqual(device.online_time, 123.45)
|
|
740
|
+
self.assertEqual(device.traffic_usage, 987654321)
|
|
741
|
+
self.assertEqual(device.signal, -55)
|
|
742
|
+
|
|
606
743
|
def test_get_status_with_perf_request(self) -> None:
|
|
607
744
|
response_status = '''
|
|
608
745
|
{
|
test/test_client_ex.py
CHANGED
|
@@ -6,6 +6,7 @@ from tplinkrouterc6u import (
|
|
|
6
6
|
Connection,
|
|
7
7
|
Firmware,
|
|
8
8
|
Status,
|
|
9
|
+
LTEStatus,
|
|
9
10
|
Device,
|
|
10
11
|
IPv4Reservation,
|
|
11
12
|
IPv4DHCPLease,
|
|
@@ -705,6 +706,76 @@ class TestTPLinkEXClient(TestCase):
|
|
|
705
706
|
self.assertEqual(check_data, '{"data":{"stack":"0,0,0,0,0,0","pstack":"0,0,0,0,0,0",'
|
|
706
707
|
'"enable":"1"},"operation":"so","oid":"DEV2_OPENVPN"}')
|
|
707
708
|
|
|
709
|
+
def test_get_lte_status(self) -> None:
|
|
710
|
+
|
|
711
|
+
DEV2_LTE_LINK_CFG = ('{"data":{"enable":"1","wispConnStat":"0","networkType":"3","endcStatus":"-1",'
|
|
712
|
+
'"connectedBand":"B3","availableBand":"B3","roamingStatus":"0","connectStatus":"4",'
|
|
713
|
+
'"simStatus":"3","simCardNumber":"0","simCardType":"0","simCardState":"1",'
|
|
714
|
+
'"simCardImsi":"0","simCardGid1":"010XXXFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF",'
|
|
715
|
+
'"dataSwitch":"1","roamSwitch":"0","prefNet":"3","timeFactroyFlag":"0",'
|
|
716
|
+
'"wlanFactoryFlag":"0","smsScEnable":"0","smsScAddress":"491712121212",'
|
|
717
|
+
'"signalStrength":"0","ipv4":"10.171.53.12","dns1v4":"62.109.121.17",'
|
|
718
|
+
'"dns2v4":"62.109.121.18","ipv4_mtu":"1500","ipv6":"2a02:3036:282:7376",'
|
|
719
|
+
'"ipv6PrefixLen":"64","dns1v6":"2a02:3018:0","dns2v6":"2a02:3018:0:40ff::bbbb",'
|
|
720
|
+
'"ipv6_mtu":"1500","ifName":"rmnet_data0","gatewayV4":"10.171.53.2",'
|
|
721
|
+
'"gatewayV6":"fe80::4d7c:fa07","netmask":"255.255.255.248","__indexPrimaryServingCell":"",'
|
|
722
|
+
'"stack":"1,0,0,0,0,0"},"operation":"go","oid":"DEV2_LTE_LINK_CFG","success":true}')
|
|
723
|
+
DEV2_XTP_LTE_INTF_CFG = ('{"data":{"enable":"1","freeDurationEnabled":"0","freeDurationWhenRoaming":"0",'
|
|
724
|
+
'"freeDurationStart":"00:00","freeDurationEnd":"00:00","cfgModified":"0",'
|
|
725
|
+
'"dailyFlow":"294896362.0000","currentDate":"1770073200","ledLimitReached":"1",'
|
|
726
|
+
'"allowDialOnce":"0","enableDataLimit":"0","autoDisconnect":"1","dataLimit":"0",'
|
|
727
|
+
'"limitType":"0","limitation":"0","warningPercent":"90","warnSimNumber":"",'
|
|
728
|
+
'"needSmsTip":"0","enablePaymentDay":"1","nextDue":"1772233200","paymentDay":"28",'
|
|
729
|
+
'"adjustStatistics":"-1","curStatistics":"0","curConnTime":"1545647",'
|
|
730
|
+
'"totalStatistics":"38819729967.0000","totalConnTime":"84667","rxFlow":"0",'
|
|
731
|
+
'"txFlow":"0","curRxSpeed":"31921","curTxSpeed":"28242","plmnLock":"0",'
|
|
732
|
+
'"enablePushNotification":"0","stack":"1,0,0,0,0,0"},"operation":"go",'
|
|
733
|
+
'"oid":"DEV2_XTP_LTE_INTF_CFG","success":true}')
|
|
734
|
+
DEV2_LTE_NET_STATUS = ('{"data":{"ussdSessionStatus":"0","ussdStatus":"0","smsUnreadCount":"0",'
|
|
735
|
+
'"smsSendCause":"0","smsSendResult":"3","rfSwitch":"1","sigLevel":"0","connStat":"4",'
|
|
736
|
+
'"roamStat":"0","regStat":"1","netType":"3","srvStat":"2","rfInfoChannel":"0",'
|
|
737
|
+
'"rfInfoBand":"0","rfInfoIf":"0","rfInfoRat":"0","rfInfoRssi":"0","rfInfoRsrp":"0",'
|
|
738
|
+
'"rfInfoRsrq":"0","rfInfoSnr":"0","rfInfoEcio":"0","region":"","callStatus":"0",'
|
|
739
|
+
'"stack":"1,0,0,0,0,0"},"operation":"go","oid":"DEV2_LTE_NET_STATUS","success":true}')
|
|
740
|
+
DEV2_LTE_PROF_STAT = ('{"data":{"activeProfType":"0","activeProfIndex":"0","spn":"","ispWhich":"0",'
|
|
741
|
+
'"ispCount":"3","ispMnc":"3","ispMcc":"262","ispName":"O2 2025","usrWhich":"0",'
|
|
742
|
+
'"usrCount":"1","usrMnc":"3","usrMcc":"262","stack":"1,0,0,0,0,0"},"operation":"go",'
|
|
743
|
+
'"oid":"DEV2_LTE_PROF_STAT","success":true}')
|
|
744
|
+
|
|
745
|
+
class TPLinkEXClientTest(TPLinkEXClient):
|
|
746
|
+
self._token = True
|
|
747
|
+
|
|
748
|
+
def _request(self, url, method='POST', data_str=None, encrypt=False):
|
|
749
|
+
if 'DEV2_LTE_LINK_CFG' in data_str:
|
|
750
|
+
return 200, DEV2_LTE_LINK_CFG
|
|
751
|
+
elif 'DEV2_XTP_LTE_INTF_CFG' in data_str:
|
|
752
|
+
return 200, DEV2_XTP_LTE_INTF_CFG
|
|
753
|
+
elif 'DEV2_LTE_NET_STATUS' in data_str:
|
|
754
|
+
return 200, DEV2_LTE_NET_STATUS
|
|
755
|
+
elif 'DEV2_LTE_PROF_STAT' in data_str:
|
|
756
|
+
return 200, DEV2_LTE_PROF_STAT
|
|
757
|
+
raise ClientException()
|
|
758
|
+
|
|
759
|
+
client = TPLinkEXClientTest('', '')
|
|
760
|
+
status = client.get_lte_status()
|
|
761
|
+
|
|
762
|
+
self.assertIsInstance(status, LTEStatus)
|
|
763
|
+
|
|
764
|
+
self.assertIsInstance(status, LTEStatus)
|
|
765
|
+
self.assertEqual(status.enable, 1)
|
|
766
|
+
self.assertEqual(status.connect_status, 4)
|
|
767
|
+
self.assertEqual(status.network_type, 3)
|
|
768
|
+
self.assertEqual(status.sim_status, 3)
|
|
769
|
+
self.assertEqual(status.sig_level, 0)
|
|
770
|
+
self.assertEqual(status.total_statistics, 38819729967)
|
|
771
|
+
self.assertEqual(status.cur_rx_speed, 31921)
|
|
772
|
+
self.assertEqual(status.cur_tx_speed, 28242)
|
|
773
|
+
self.assertEqual(status.sms_unread_count, 0)
|
|
774
|
+
self.assertEqual(status.rsrp, 0)
|
|
775
|
+
self.assertEqual(status.rsrq, 0)
|
|
776
|
+
self.assertEqual(status.snr, 0)
|
|
777
|
+
self.assertEqual(status.isp_name, 'O2 2025')
|
|
778
|
+
|
|
708
779
|
|
|
709
780
|
if __name__ == '__main__':
|
|
710
781
|
main()
|
test/test_client_sg.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from hashlib import sha256
|
|
3
|
+
from unittest import main, TestCase
|
|
4
|
+
from unittest.mock import patch, Mock
|
|
5
|
+
|
|
6
|
+
from tplinkrouterc6u import TplinkRouterSG, ClientException
|
|
7
|
+
from test_client_c6u import TestTPLinkClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestTPLinkClientSG(TestTPLinkClient):
|
|
11
|
+
"""Inherits get_status and other tests from TestTPLinkClient."""
|
|
12
|
+
|
|
13
|
+
router_class = TplinkRouterSG
|
|
14
|
+
game_accelerator_path = 'admin/smart_network?form=game_accelerator&operation=loadDevice'
|
|
15
|
+
openvpn_config_path = 'admin/openvpn?form=config&operation=read'
|
|
16
|
+
pptpd_config_path = 'admin/pptpd?form=config&operation=read'
|
|
17
|
+
vpn_uses_data_param = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestTplinkRouterSGUnit(TestCase):
|
|
21
|
+
"""Unit tests specific to TplinkRouterSG authentication and encryption."""
|
|
22
|
+
|
|
23
|
+
def test_supports_password_too_long(self) -> None:
|
|
24
|
+
long_password = 'a' * 126
|
|
25
|
+
client = TplinkRouterSG('http://192.168.0.1', long_password)
|
|
26
|
+
self.assertFalse(client.supports())
|
|
27
|
+
|
|
28
|
+
@patch('tplinkrouterc6u.client.sg.post')
|
|
29
|
+
def test_check_sg_certification_match(self, mock_post: Mock) -> None:
|
|
30
|
+
response = Mock()
|
|
31
|
+
response.json.return_value = {
|
|
32
|
+
'data': {
|
|
33
|
+
'certification': ['SG CLS L1 STAGE2', 'OTHER']
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
mock_post.return_value = response
|
|
37
|
+
|
|
38
|
+
client = TplinkRouterSG('http://192.168.0.1', 'testpassword')
|
|
39
|
+
result = client._check_sg_certification()
|
|
40
|
+
|
|
41
|
+
self.assertTrue(result)
|
|
42
|
+
self.assertEqual(mock_post.call_count, 1)
|
|
43
|
+
call_args = mock_post.call_args
|
|
44
|
+
self.assertIn('device_config', call_args[0][0])
|
|
45
|
+
|
|
46
|
+
@patch('tplinkrouterc6u.client.sg.post')
|
|
47
|
+
def test_check_sg_certification_no_match(self, mock_post: Mock) -> None:
|
|
48
|
+
response = Mock()
|
|
49
|
+
response.json.return_value = {
|
|
50
|
+
'data': {
|
|
51
|
+
'certification': ['SOME_OTHER_CERT']
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
mock_post.return_value = response
|
|
55
|
+
|
|
56
|
+
client = TplinkRouterSG('http://192.168.0.1', 'testpassword')
|
|
57
|
+
result = client._check_sg_certification()
|
|
58
|
+
|
|
59
|
+
self.assertFalse(result)
|
|
60
|
+
|
|
61
|
+
@patch('tplinkrouterc6u.client.sg.post')
|
|
62
|
+
def test_authorize_success(self, mock_post: Mock) -> None:
|
|
63
|
+
pwd_keys_response = Mock()
|
|
64
|
+
pwd_keys_response.json.return_value = {
|
|
65
|
+
'data': {
|
|
66
|
+
'password': ['mock_pwd_nn', '010001']
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
auth_keys_response = Mock()
|
|
71
|
+
auth_keys_response.json.return_value = {
|
|
72
|
+
'data': {
|
|
73
|
+
'seq': 100,
|
|
74
|
+
'key': ['mock_auth_nn', '010001']
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
login_response = Mock()
|
|
79
|
+
login_response.json.return_value = {'data': 'encrypted_login_blob'}
|
|
80
|
+
login_response.headers = {
|
|
81
|
+
'set-cookie': 'sysauth=test_sysauth_value; path=/'
|
|
82
|
+
}
|
|
83
|
+
login_response.text = 'mock response text'
|
|
84
|
+
|
|
85
|
+
mock_post.side_effect = [
|
|
86
|
+
pwd_keys_response,
|
|
87
|
+
auth_keys_response,
|
|
88
|
+
login_response,
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
client = TplinkRouterSG('http://192.168.0.1', 'testpassword')
|
|
92
|
+
|
|
93
|
+
login_result = json.dumps({
|
|
94
|
+
'success': True,
|
|
95
|
+
'data': {'stok': 'test_stok_12345'}
|
|
96
|
+
})
|
|
97
|
+
with patch.object(client, '_rsa_v15_encrypt', return_value='encrypted_pwd_hex'), \
|
|
98
|
+
patch.object(client, '_aes_encrypt', return_value='encrypted_data_b64'), \
|
|
99
|
+
patch.object(client, '_build_login_signature', return_value='mock_sign'), \
|
|
100
|
+
patch.object(client, '_aes_decrypt', return_value=login_result):
|
|
101
|
+
client.authorize()
|
|
102
|
+
|
|
103
|
+
self.assertTrue(client._logged)
|
|
104
|
+
self.assertEqual(client._stok, 'test_stok_12345')
|
|
105
|
+
self.assertEqual(client._sysauth, 'test_sysauth_value')
|
|
106
|
+
self.assertEqual(mock_post.call_count, 3)
|
|
107
|
+
|
|
108
|
+
first_call = mock_post.call_args_list[0]
|
|
109
|
+
self.assertIn('login?form=keys', first_call[0][0])
|
|
110
|
+
|
|
111
|
+
second_call = mock_post.call_args_list[1]
|
|
112
|
+
self.assertIn('login?form=auth', second_call[0][0])
|
|
113
|
+
|
|
114
|
+
third_call = mock_post.call_args_list[2]
|
|
115
|
+
self.assertIn('login?form=login', third_call[0][0])
|
|
116
|
+
|
|
117
|
+
@patch('tplinkrouterc6u.client.sg.post')
|
|
118
|
+
def test_authorize_failure(self, mock_post: Mock) -> None:
|
|
119
|
+
pwd_keys_response = Mock()
|
|
120
|
+
pwd_keys_response.json.return_value = {
|
|
121
|
+
'data': {
|
|
122
|
+
'password': ['mock_pwd_nn', '010001']
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
auth_keys_response = Mock()
|
|
127
|
+
auth_keys_response.json.return_value = {
|
|
128
|
+
'data': {
|
|
129
|
+
'seq': 100,
|
|
130
|
+
'key': ['mock_auth_nn', '010001']
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
login_response = Mock()
|
|
135
|
+
login_response.json.return_value = {'data': 'encrypted_login_blob'}
|
|
136
|
+
login_response.headers = {}
|
|
137
|
+
login_response.text = 'mock error response'
|
|
138
|
+
|
|
139
|
+
mock_post.side_effect = [
|
|
140
|
+
pwd_keys_response,
|
|
141
|
+
auth_keys_response,
|
|
142
|
+
login_response,
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
client = TplinkRouterSG('http://192.168.0.1', 'wrongpassword')
|
|
146
|
+
|
|
147
|
+
login_result = json.dumps({
|
|
148
|
+
'success': False,
|
|
149
|
+
'data': {'errorcode': 'invalid password'}
|
|
150
|
+
})
|
|
151
|
+
with patch.object(client, '_rsa_v15_encrypt', return_value='encrypted_pwd_hex'), \
|
|
152
|
+
patch.object(client, '_aes_encrypt', return_value='encrypted_data_b64'), \
|
|
153
|
+
patch.object(client, '_build_login_signature', return_value='mock_sign'), \
|
|
154
|
+
patch.object(client, '_aes_decrypt', return_value=login_result):
|
|
155
|
+
with self.assertRaises(ClientException) as context:
|
|
156
|
+
client.authorize()
|
|
157
|
+
|
|
158
|
+
self.assertIn('Login failed', str(context.exception))
|
|
159
|
+
self.assertFalse(client._logged)
|
|
160
|
+
|
|
161
|
+
def test_authorize_uses_username(self) -> None:
|
|
162
|
+
"""Verify SHA256 hash uses self.username, not hardcoded 'admin'."""
|
|
163
|
+
client = TplinkRouterSG(
|
|
164
|
+
'http://192.168.0.1', 'testpassword', username='customuser')
|
|
165
|
+
|
|
166
|
+
expected_hash = sha256(
|
|
167
|
+
('customuser' + 'testpassword').encode()).hexdigest()
|
|
168
|
+
admin_hash = sha256(
|
|
169
|
+
('admin' + 'testpassword').encode()).hexdigest()
|
|
170
|
+
|
|
171
|
+
# Simulate the hash computation that happens in authorize()
|
|
172
|
+
client._hash = sha256(
|
|
173
|
+
(client.username + client.password).encode()).hexdigest()
|
|
174
|
+
|
|
175
|
+
self.assertEqual(client._hash, expected_hash)
|
|
176
|
+
self.assertNotEqual(client._hash, admin_hash)
|
|
177
|
+
|
|
178
|
+
@patch('tplinkrouterc6u.client.sg.post')
|
|
179
|
+
def test_request_hmac_signature(self, mock_post: Mock) -> None:
|
|
180
|
+
"""Verify non-login requests use HMAC-SHA256 signature."""
|
|
181
|
+
client = TplinkRouterSG('http://192.168.0.1', 'testpassword')
|
|
182
|
+
client._logged = True
|
|
183
|
+
client._stok = 'test_stok'
|
|
184
|
+
client._sysauth = 'test_sysauth'
|
|
185
|
+
client._aes_key = '1234567890123456'
|
|
186
|
+
client._aes_iv = '6543210987654321'
|
|
187
|
+
client._hash = 'fakehash'
|
|
188
|
+
client._seq = 100
|
|
189
|
+
|
|
190
|
+
response = Mock()
|
|
191
|
+
decrypted_data = json.dumps({
|
|
192
|
+
'success': True,
|
|
193
|
+
'data': {'key': 'value'}
|
|
194
|
+
})
|
|
195
|
+
response.json.return_value = {'data': 'encrypted'}
|
|
196
|
+
|
|
197
|
+
mock_post.return_value = response
|
|
198
|
+
|
|
199
|
+
with patch.object(
|
|
200
|
+
client, '_aes_decrypt', return_value=decrypted_data
|
|
201
|
+
):
|
|
202
|
+
result = client.request(
|
|
203
|
+
'admin/status?form=all', 'operation=read')
|
|
204
|
+
|
|
205
|
+
self.assertEqual(result, {'key': 'value'})
|
|
206
|
+
|
|
207
|
+
call_kwargs = mock_post.call_args
|
|
208
|
+
body = call_kwargs[1]['data']
|
|
209
|
+
self.assertTrue(body.startswith('sign='))
|
|
210
|
+
self.assertIn('&data=', body)
|
|
211
|
+
|
|
212
|
+
# Hash should have been updated to SHA256 of the encrypted data
|
|
213
|
+
self.assertNotEqual(client._hash, 'fakehash')
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == '__main__':
|
|
217
|
+
main()
|
tplinkrouterc6u/__init__.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from tplinkrouterc6u.client.c6u import TplinkRouter, TplinkRouterV1_11
|
|
2
|
+
from tplinkrouterc6u.client.sg import TplinkRouterSG
|
|
2
3
|
from tplinkrouterc6u.client.deco import TPLinkDecoClient
|
|
3
4
|
from tplinkrouterc6u.client_abstract import AbstractRouter
|
|
4
5
|
from tplinkrouterc6u.client.mr import TPLinkMRClient, TPLinkMRClientGCM
|
tplinkrouterc6u/client/c6u.py
CHANGED
|
@@ -348,18 +348,27 @@ class TplinkBaseRouter(AbstractRouter, TplinkRequest):
|
|
|
348
348
|
|
|
349
349
|
if smart_network:
|
|
350
350
|
for item in smart_network:
|
|
351
|
-
|
|
351
|
+
mac = item.get('mac')
|
|
352
|
+
if not mac:
|
|
353
|
+
continue
|
|
354
|
+
|
|
355
|
+
if mac not in devices:
|
|
352
356
|
conn = self._map_wire_type(item.get('deviceTag'), not item.get('isGuest'))
|
|
353
|
-
devices[
|
|
354
|
-
|
|
357
|
+
devices[mac] = Device(conn, get_mac(item.get('mac', '00:00:00:00:00:00')),
|
|
358
|
+
get_ip(item.get('ip', '0.0.0.0')), item.get('deviceName', ''))
|
|
355
359
|
if conn.is_iot():
|
|
356
360
|
if status.iot_clients_total is None:
|
|
357
361
|
status.iot_clients_total = 0
|
|
358
362
|
status.iot_clients_total += 1
|
|
359
363
|
|
|
360
|
-
devices[
|
|
361
|
-
|
|
362
|
-
|
|
364
|
+
device = devices[mac]
|
|
365
|
+
device.down_speed = item.get('downloadSpeed', item.get('downSpeed'))
|
|
366
|
+
device.up_speed = item.get('uploadSpeed', item.get('upSpeed'))
|
|
367
|
+
device.tx_rate = item.get('txrate', item.get('txRate'))
|
|
368
|
+
device.rx_rate = item.get('rxrate', item.get('rxRate'))
|
|
369
|
+
device.online_time = item.get('onlineTime', item.get('online_time'))
|
|
370
|
+
device.traffic_usage = item.get('trafficUsage', item.get('trafficUsed'))
|
|
371
|
+
device.signal = int(item.get('signal')) if item.get('signal') else None
|
|
363
372
|
|
|
364
373
|
try:
|
|
365
374
|
wireless_stats = self.request('admin/wireless?form=statistics', 'operation=load')
|
|
@@ -514,7 +523,10 @@ class TplinkRouterV1_11(TplinkBaseRouter):
|
|
|
514
523
|
try:
|
|
515
524
|
self._request_pwd()
|
|
516
525
|
# V1_11 uses 2048-bit RSA = 512 hex chars, older firmware uses 1024-bit = 256 chars
|
|
517
|
-
|
|
526
|
+
if len(self._pwdNN) >= 512:
|
|
527
|
+
self.authorize()
|
|
528
|
+
self.logout()
|
|
529
|
+
return True
|
|
518
530
|
except Exception:
|
|
519
531
|
return False
|
|
520
532
|
|
|
@@ -534,7 +546,7 @@ class TplinkRouterV1_11(TplinkBaseRouter):
|
|
|
534
546
|
self._pwdNN = data[self._data_block]['password'][0]
|
|
535
547
|
self._pwdEE = data[self._data_block]['password'][1]
|
|
536
548
|
except Exception as e:
|
|
537
|
-
error = ('
|
|
549
|
+
error = ('TplinkRouterV1_11 - {} - Failed to get encryption keys! Error - {}; Response - {}'
|
|
538
550
|
.format(self.__class__.__name__, e, response.text))
|
|
539
551
|
if self._logger:
|
|
540
552
|
self._logger.debug(error)
|
|
@@ -563,7 +575,7 @@ class TplinkRouterV1_11(TplinkBaseRouter):
|
|
|
563
575
|
if not data.get('success'):
|
|
564
576
|
error_info = data.get(self._data_block, {})
|
|
565
577
|
raise ClientException(
|
|
566
|
-
'
|
|
578
|
+
'TplinkRouterV1_11 - {} - Login failed: {}'.format(
|
|
567
579
|
self.__class__.__name__,
|
|
568
580
|
error_info.get('errorcode', 'unknown error')
|
|
569
581
|
)
|
|
@@ -582,7 +594,7 @@ class TplinkRouterV1_11(TplinkBaseRouter):
|
|
|
582
594
|
except ClientException:
|
|
583
595
|
raise
|
|
584
596
|
except Exception as e:
|
|
585
|
-
error = ('
|
|
597
|
+
error = ('TplinkRouterV1_11 - {} - Cannot authorize! Error - {}; Response - {}'
|
|
586
598
|
.format(self.__class__.__name__, e, response.text))
|
|
587
599
|
if self._logger:
|
|
588
600
|
self._logger.debug(error)
|
tplinkrouterc6u/client/ex.py
CHANGED
|
@@ -13,6 +13,7 @@ from tplinkrouterc6u.common.dataclass import (
|
|
|
13
13
|
IPv4Reservation,
|
|
14
14
|
IPv4DHCPLease,
|
|
15
15
|
IPv4Status,
|
|
16
|
+
LTEStatus,
|
|
16
17
|
VPNStatus)
|
|
17
18
|
from tplinkrouterc6u.common.exception import ClientException, ClientError
|
|
18
19
|
from tplinkrouterc6u.client.mr import TPLinkMRClientBase, TPLinkMRClientBaseGCM
|
|
@@ -238,6 +239,38 @@ class TPLinkEXClient(TPLinkMRClientBase):
|
|
|
238
239
|
]
|
|
239
240
|
self.req_act(acts)
|
|
240
241
|
|
|
242
|
+
def get_lte_status(self) -> LTEStatus:
|
|
243
|
+
status = LTEStatus()
|
|
244
|
+
acts = [
|
|
245
|
+
self.ActItem(self.ActItem.GET, 'DEV2_LTE_LINK_CFG', '1,0,0,0,0,0',
|
|
246
|
+
attrs=['enable', 'connectStatus', 'networkType', 'simStatus']),
|
|
247
|
+
self.ActItem(self.ActItem.GET, 'DEV2_XTP_LTE_INTF_CFG', '1,0,0,0,0,0',
|
|
248
|
+
attrs=['totalStatistics', 'curRxSpeed', 'curTxSpeed']),
|
|
249
|
+
self.ActItem(self.ActItem.GET, 'DEV2_LTE_NET_STATUS', '1,0,0,0,0,0',
|
|
250
|
+
attrs=['smsUnreadCount', 'sigLevel', 'rfInfoRsrp', 'rfInfoRsrq', 'rfInfoSnr']),
|
|
251
|
+
self.ActItem(self.ActItem.GET, 'DEV2_LTE_PROF_STAT', '1,0,0,0,0,0', attrs=['ispName']),
|
|
252
|
+
]
|
|
253
|
+
_, values = self.req_act(acts)
|
|
254
|
+
|
|
255
|
+
status.enable = int(values[0]['enable'])
|
|
256
|
+
status.connect_status = int(values[0]['connectStatus'])
|
|
257
|
+
status.network_type = int(values[0]['networkType'])
|
|
258
|
+
status.sim_status = int(values[0]['simStatus'])
|
|
259
|
+
|
|
260
|
+
status.total_statistics = int(float(values[1]['totalStatistics']))
|
|
261
|
+
status.cur_rx_speed = int(values[1]['curRxSpeed'])
|
|
262
|
+
status.cur_tx_speed = int(values[1]['curTxSpeed'])
|
|
263
|
+
|
|
264
|
+
status.sms_unread_count = int(values[2]['smsUnreadCount'])
|
|
265
|
+
status.sig_level = int(values[2]['sigLevel'])
|
|
266
|
+
status.rsrp = int(values[2]['rfInfoRsrp'])
|
|
267
|
+
status.rsrq = int(values[2]['rfInfoRsrq'])
|
|
268
|
+
status.snr = int(values[2]['rfInfoSnr'])
|
|
269
|
+
|
|
270
|
+
status.isp_name = values[3]['ispName']
|
|
271
|
+
|
|
272
|
+
return status
|
|
273
|
+
|
|
241
274
|
def req_act(self, acts: list):
|
|
242
275
|
'''
|
|
243
276
|
Requests ACTs via the cgi_gdpr proxy
|
tplinkrouterc6u/client/mr200.py
CHANGED
|
@@ -91,7 +91,8 @@ class TPLinkMR200Client(TPLinkMRClient):
|
|
|
91
91
|
status = LTEStatus()
|
|
92
92
|
acts = [
|
|
93
93
|
self.ActItem(self.ActItem.GET, 'WAN_LTE_LINK_CFG', '2,1,0,0,0,0',
|
|
94
|
-
attrs=['enable', 'connectStatus', 'networkType', 'roamingStatus', 'simStatus'
|
|
94
|
+
attrs=['enable', 'connectStatus', 'networkType', 'roamingStatus', 'simStatus',
|
|
95
|
+
'signalStrength']),
|
|
95
96
|
self.ActItem(self.ActItem.GET, 'WAN_LTE_INTF_CFG', '2,0,0,0,0,0',
|
|
96
97
|
attrs=['dataLimit', 'enablePaymentDay', 'curStatistics', 'totalStatistics', 'enableDataLimit',
|
|
97
98
|
'limitation',
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TP-Link router client for SG_L1_S2 / CE_RED certified devices.
|
|
3
|
+
|
|
4
|
+
Routers with these certifications (e.g., Archer BE3600, Wi-Fi 7 models)
|
|
5
|
+
use an enhanced encryption scheme:
|
|
6
|
+
1. SHA256 hash instead of MD5 for authentication
|
|
7
|
+
2. PKCS1-OAEP RSA padding for login signature encryption
|
|
8
|
+
3. HMAC-SHA256 for non-login request signatures
|
|
9
|
+
4. Dynamic hash replacement: SHA256(encrypted_data) per request
|
|
10
|
+
|
|
11
|
+
Fixes: https://github.com/AlexandrErohin/home-assistant-tplink-router/issues/220
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import hmac
|
|
15
|
+
import json
|
|
16
|
+
from re import search
|
|
17
|
+
from hashlib import sha256
|
|
18
|
+
from base64 import b64encode, b64decode
|
|
19
|
+
from random import randint
|
|
20
|
+
from logging import Logger
|
|
21
|
+
from urllib.parse import quote
|
|
22
|
+
|
|
23
|
+
from Crypto.PublicKey.RSA import construct
|
|
24
|
+
from Crypto.Cipher import PKCS1_OAEP, PKCS1_v1_5, AES
|
|
25
|
+
from Crypto.Util.Padding import pad, unpad
|
|
26
|
+
from binascii import hexlify
|
|
27
|
+
from requests import post
|
|
28
|
+
|
|
29
|
+
from tplinkrouterc6u.client.c6u import TplinkBaseRouter
|
|
30
|
+
from tplinkrouterc6u.common.exception import ClientException, ClientError
|
|
31
|
+
|
|
32
|
+
# SG_L1_S2 and CE_RED certifications that trigger enhanced encryption
|
|
33
|
+
SG_CERTIFICATIONS = ['SG CLS L1 STAGE2', 'EU CE RED']
|
|
34
|
+
|
|
35
|
+
SIGNATURE_OFFSET = 53
|
|
36
|
+
AES_KEY_LEN = 16
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TplinkRouterSG(TplinkBaseRouter):
|
|
40
|
+
"""
|
|
41
|
+
Client for TP-Link routers with SG_L1_S2 / CE_RED certification.
|
|
42
|
+
|
|
43
|
+
These routers use SHA256 + OAEP + HMAC-SHA256 instead of the standard
|
|
44
|
+
MD5 + PKCS1_v1_5 + RSA encryption scheme.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, host: str, password: str, username: str = 'admin',
|
|
48
|
+
logger: Logger = None, verify_ssl: bool = True,
|
|
49
|
+
timeout: int = 30) -> None:
|
|
50
|
+
super().__init__(host, password, username, logger, verify_ssl, timeout)
|
|
51
|
+
self._aes_key = ''
|
|
52
|
+
self._aes_iv = ''
|
|
53
|
+
self._hash = ''
|
|
54
|
+
self._seq = 0
|
|
55
|
+
self._nn = ''
|
|
56
|
+
self._ee = ''
|
|
57
|
+
self._pwdNN = ''
|
|
58
|
+
self._pwdEE = ''
|
|
59
|
+
self._data_block = 'data'
|
|
60
|
+
|
|
61
|
+
def supports(self) -> bool:
|
|
62
|
+
"""Check if this router uses SG/CE_RED encryption."""
|
|
63
|
+
if len(self.password) > 125:
|
|
64
|
+
return False
|
|
65
|
+
try:
|
|
66
|
+
if not self._check_sg_certification():
|
|
67
|
+
return False
|
|
68
|
+
self._request_pwd_keys()
|
|
69
|
+
return True
|
|
70
|
+
except Exception:
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
def _check_sg_certification(self) -> bool:
|
|
74
|
+
"""Check if the router has SG_L1_S2 or CE_RED certification."""
|
|
75
|
+
url = '{}/cgi-bin/luci/;stok=/device_config?form=config'.format(self.host)
|
|
76
|
+
response = post(
|
|
77
|
+
url, data='operation=read',
|
|
78
|
+
headers=self._headers_login,
|
|
79
|
+
timeout=self.timeout, verify=self._verify_ssl,
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
data = response.json()
|
|
83
|
+
certs = data.get('data', {}).get('certification', [])
|
|
84
|
+
return any(c in SG_CERTIFICATIONS for c in certs)
|
|
85
|
+
except Exception:
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
def _generate_aes_key(self) -> None:
|
|
89
|
+
"""Generate random AES key and IV (16 random digits, matching JS behavior)."""
|
|
90
|
+
self._aes_key = ''.join([str(randint(0, 9)) for _ in range(AES_KEY_LEN)])
|
|
91
|
+
self._aes_iv = ''.join([str(randint(0, 9)) for _ in range(AES_KEY_LEN)])
|
|
92
|
+
|
|
93
|
+
def _aes_encrypt(self, data: str) -> str:
|
|
94
|
+
"""AES-CBC encrypt with PKCS7 padding."""
|
|
95
|
+
cipher = AES.new(self._aes_key.encode(), AES.MODE_CBC, self._aes_iv.encode())
|
|
96
|
+
return b64encode(cipher.encrypt(pad(data.encode(), AES.block_size))).decode()
|
|
97
|
+
|
|
98
|
+
def _aes_decrypt(self, data: str) -> str:
|
|
99
|
+
"""AES-CBC decrypt with PKCS7 unpadding."""
|
|
100
|
+
cipher = AES.new(self._aes_key.encode(), AES.MODE_CBC, self._aes_iv.encode())
|
|
101
|
+
return unpad(cipher.decrypt(b64decode(data)), AES.block_size).decode()
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _rsa_v15_encrypt(data: str, n_hex: str, e_hex: str) -> str:
|
|
105
|
+
"""RSA encrypt with PKCS1 v1.5 padding (used for password encryption)."""
|
|
106
|
+
key = construct((int(n_hex, 16), int(e_hex, 16)))
|
|
107
|
+
cipher = PKCS1_v1_5.new(key)
|
|
108
|
+
result = hexlify(cipher.encrypt(data.encode())).decode()
|
|
109
|
+
key_len = len(n_hex)
|
|
110
|
+
return result.zfill(key_len) if len(result) < key_len else result
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _rsa_oaep_encrypt(data: str, n_hex: str, e_hex: str) -> str:
|
|
114
|
+
"""RSA encrypt with PKCS1-OAEP padding (used for login signature)."""
|
|
115
|
+
key = construct((int(n_hex, 16), int(e_hex, 16)))
|
|
116
|
+
cipher = PKCS1_OAEP.new(key)
|
|
117
|
+
result = hexlify(cipher.encrypt(data.encode())).decode()
|
|
118
|
+
key_len = len(n_hex)
|
|
119
|
+
return result.zfill(key_len) if len(result) < key_len else result
|
|
120
|
+
|
|
121
|
+
def _get_aes_formatted_key(self) -> str:
|
|
122
|
+
return 'k={}&i={}'.format(self._aes_key, self._aes_iv)
|
|
123
|
+
|
|
124
|
+
def _request_pwd_keys(self) -> None:
|
|
125
|
+
"""Get RSA public key for password encryption."""
|
|
126
|
+
url = '{}/cgi-bin/luci/;stok=/login?form=keys'.format(self.host)
|
|
127
|
+
response = post(
|
|
128
|
+
url, params={'operation': 'read'},
|
|
129
|
+
timeout=self.timeout, verify=self._verify_ssl,
|
|
130
|
+
)
|
|
131
|
+
try:
|
|
132
|
+
data = response.json()
|
|
133
|
+
self._pwdNN = data['data']['password'][0]
|
|
134
|
+
self._pwdEE = data['data']['password'][1]
|
|
135
|
+
except Exception as e:
|
|
136
|
+
error = ('TplinkRouterSG - Failed to get password keys: {}'.format(e))
|
|
137
|
+
if self._logger:
|
|
138
|
+
self._logger.debug(error)
|
|
139
|
+
raise ClientException(error)
|
|
140
|
+
|
|
141
|
+
def _request_auth_keys(self) -> None:
|
|
142
|
+
"""Get sequence number and RSA public key for data encryption."""
|
|
143
|
+
url = '{}/cgi-bin/luci/;stok=/login?form=auth'.format(self.host)
|
|
144
|
+
response = post(
|
|
145
|
+
url, params={'operation': 'read'},
|
|
146
|
+
timeout=self.timeout, verify=self._verify_ssl,
|
|
147
|
+
)
|
|
148
|
+
try:
|
|
149
|
+
data = response.json()
|
|
150
|
+
self._seq = data['data']['seq']
|
|
151
|
+
self._nn = data['data']['key'][0]
|
|
152
|
+
self._ee = data['data']['key'][1]
|
|
153
|
+
except Exception as e:
|
|
154
|
+
error = ('TplinkRouterSG - Failed to get auth keys: {}'.format(e))
|
|
155
|
+
if self._logger:
|
|
156
|
+
self._logger.debug(error)
|
|
157
|
+
raise ClientException(error)
|
|
158
|
+
|
|
159
|
+
def _build_login_signature(self, data_len: int) -> str:
|
|
160
|
+
"""Build RSA-OAEP encrypted signature for login requests."""
|
|
161
|
+
sign_str = '{}&h={}&s={}'.format(
|
|
162
|
+
self._get_aes_formatted_key(), self._hash, self._seq + data_len)
|
|
163
|
+
sign = ''
|
|
164
|
+
for i in range(0, len(sign_str), SIGNATURE_OFFSET):
|
|
165
|
+
chunk = sign_str[i:i + SIGNATURE_OFFSET]
|
|
166
|
+
sign += self._rsa_oaep_encrypt(chunk, self._nn, self._ee)
|
|
167
|
+
return sign
|
|
168
|
+
|
|
169
|
+
def _build_request_signature(self, data_len: int) -> str:
|
|
170
|
+
"""Build HMAC-SHA256 signature for non-login requests."""
|
|
171
|
+
sign_str = 'h={}&s={}'.format(self._hash, self._seq + data_len)
|
|
172
|
+
aes_key = self._get_aes_formatted_key()
|
|
173
|
+
sign = ''
|
|
174
|
+
for i in range(0, len(sign_str), SIGNATURE_OFFSET):
|
|
175
|
+
chunk = sign_str[i:i + SIGNATURE_OFFSET]
|
|
176
|
+
h = hmac.new(aes_key.encode(), chunk.encode(), sha256)
|
|
177
|
+
sign += h.hexdigest()
|
|
178
|
+
return sign
|
|
179
|
+
|
|
180
|
+
def authorize(self) -> None:
|
|
181
|
+
"""Authorize using SHA256 + OAEP encryption scheme."""
|
|
182
|
+
self._request_pwd_keys()
|
|
183
|
+
self._request_auth_keys()
|
|
184
|
+
|
|
185
|
+
# SHA256 hash of username + password (not MD5)
|
|
186
|
+
self._hash = sha256((self.username + self.password).encode()).hexdigest()
|
|
187
|
+
|
|
188
|
+
# Generate AES session key
|
|
189
|
+
self._generate_aes_key()
|
|
190
|
+
|
|
191
|
+
# RSA encrypt password with PKCS1 v1.5 (password uses v1.5, not OAEP)
|
|
192
|
+
encrypted_pwd = self._rsa_v15_encrypt(self.password, self._pwdNN, self._pwdEE)
|
|
193
|
+
|
|
194
|
+
# Build and AES-encrypt the login payload
|
|
195
|
+
login_data = 'operation=login&password={}&confirm=true'.format(encrypted_pwd)
|
|
196
|
+
encrypted_data = self._aes_encrypt(login_data)
|
|
197
|
+
|
|
198
|
+
# Build OAEP-encrypted signature
|
|
199
|
+
sign = self._build_login_signature(len(encrypted_data))
|
|
200
|
+
|
|
201
|
+
# Send login request
|
|
202
|
+
url = '{}/cgi-bin/luci/;stok=/login?form=login'.format(self.host)
|
|
203
|
+
body = 'sign={}&data={}'.format(sign, quote(encrypted_data))
|
|
204
|
+
response = post(
|
|
205
|
+
url, data=body, headers=self._headers_login,
|
|
206
|
+
timeout=self.timeout, verify=self._verify_ssl,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
resp = response.json()
|
|
211
|
+
decrypted = json.loads(self._aes_decrypt(resp['data']))
|
|
212
|
+
|
|
213
|
+
if not decrypted.get('success'):
|
|
214
|
+
error_data = decrypted.get('data', {})
|
|
215
|
+
raise ClientException(
|
|
216
|
+
'TplinkRouterSG - Login failed: {}'.format(
|
|
217
|
+
error_data.get('errorcode', 'unknown')))
|
|
218
|
+
|
|
219
|
+
self._stok = decrypted['data']['stok']
|
|
220
|
+
if 'set-cookie' in response.headers:
|
|
221
|
+
regex_result = search(
|
|
222
|
+
r'sysauth=([^;]+)', response.headers['set-cookie'])
|
|
223
|
+
if regex_result:
|
|
224
|
+
self._sysauth = regex_result.group(1)
|
|
225
|
+
self._logged = True
|
|
226
|
+
|
|
227
|
+
except ClientException:
|
|
228
|
+
raise
|
|
229
|
+
except Exception as e:
|
|
230
|
+
error = ('TplinkRouterSG - Cannot authorize! Error - {}; Response - {}'
|
|
231
|
+
.format(e, response.text[:200] if response.text else ''))
|
|
232
|
+
if self._logger:
|
|
233
|
+
self._logger.debug(error)
|
|
234
|
+
raise ClientException(error)
|
|
235
|
+
|
|
236
|
+
def request(self, path: str, data: str,
|
|
237
|
+
ignore_response: bool = False,
|
|
238
|
+
ignore_errors: bool = False) -> dict | None:
|
|
239
|
+
"""Make an authenticated request using HMAC-SHA256 signatures."""
|
|
240
|
+
if self._logged is False:
|
|
241
|
+
raise Exception('Not authorised')
|
|
242
|
+
|
|
243
|
+
# AES encrypt the request data
|
|
244
|
+
encrypted_data = self._aes_encrypt(data)
|
|
245
|
+
|
|
246
|
+
# REPLACE_HASH: update hash with SHA256 of encrypted data
|
|
247
|
+
self._hash = sha256(encrypted_data.encode()).hexdigest()
|
|
248
|
+
|
|
249
|
+
# Build HMAC-SHA256 signature
|
|
250
|
+
sign = self._build_request_signature(len(encrypted_data))
|
|
251
|
+
|
|
252
|
+
url = '{}/cgi-bin/luci/;stok={}/{}'.format(self.host, self._stok, path)
|
|
253
|
+
body = 'sign={}&data={}'.format(sign, quote(encrypted_data))
|
|
254
|
+
response = post(
|
|
255
|
+
url, data=body, headers=self._headers_request,
|
|
256
|
+
cookies={'sysauth': self._sysauth},
|
|
257
|
+
timeout=self.timeout, verify=self._verify_ssl,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if ignore_response:
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
resp = response.json()
|
|
265
|
+
decrypted = json.loads(self._aes_decrypt(resp['data']))
|
|
266
|
+
|
|
267
|
+
if self._is_valid_response(decrypted):
|
|
268
|
+
return decrypted.get(self._data_block)
|
|
269
|
+
elif ignore_errors:
|
|
270
|
+
return decrypted
|
|
271
|
+
except Exception as e:
|
|
272
|
+
error = ('TplinkRouterSG - Unknown response - {}; Request {} - Response {}'
|
|
273
|
+
.format(e, path, response.text[:200] if response.text else ''))
|
|
274
|
+
if self._logger:
|
|
275
|
+
self._logger.debug(error)
|
|
276
|
+
raise ClientError(error)
|
|
277
|
+
|
|
278
|
+
error = ('TplinkRouterSG - Response with error; Request {} - Response {}'
|
|
279
|
+
.format(path, response.text[:200] if response.text else ''))
|
|
280
|
+
if self._logger:
|
|
281
|
+
self._logger.debug(error)
|
|
282
|
+
raise ClientError(error)
|
|
283
|
+
|
|
284
|
+
def _is_valid_response(self, data: dict) -> bool:
|
|
285
|
+
return 'success' in data and data['success'] and self._data_block in data
|
|
@@ -22,6 +22,10 @@ class Device:
|
|
|
22
22
|
packets_received: int | None = None
|
|
23
23
|
down_speed: int | None = None
|
|
24
24
|
up_speed: int | None = None
|
|
25
|
+
tx_rate: int | None = None
|
|
26
|
+
rx_rate: int | None = None
|
|
27
|
+
online_time: float | None = None
|
|
28
|
+
traffic_usage: int | None = None
|
|
25
29
|
signal: int | None = None
|
|
26
30
|
active: bool = True
|
|
27
31
|
|
tplinkrouterc6u/provider.py
CHANGED
|
@@ -3,6 +3,7 @@ from logging import Logger
|
|
|
3
3
|
from tplinkrouterc6u import TPLinkXDRClient
|
|
4
4
|
from tplinkrouterc6u.common.exception import ClientException
|
|
5
5
|
from tplinkrouterc6u.client.c6u import TplinkRouter, TplinkRouterV1_11
|
|
6
|
+
from tplinkrouterc6u.client.sg import TplinkRouterSG
|
|
6
7
|
from tplinkrouterc6u.client.deco import TPLinkDecoClient
|
|
7
8
|
from tplinkrouterc6u.client_abstract import AbstractRouter
|
|
8
9
|
from tplinkrouterc6u.client.mr import TPLinkMRClient, TPLinkMRClientGCM
|
|
@@ -37,6 +38,7 @@ class TplinkRouterProvider:
|
|
|
37
38
|
TPLinkDecoClient,
|
|
38
39
|
TPLinkXDRClient,
|
|
39
40
|
TPLinkRClient,
|
|
41
|
+
TplinkRouterSG,
|
|
40
42
|
TplinkRouterV1_11,
|
|
41
43
|
TplinkRouter,
|
|
42
44
|
TplinkC80Router,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tplinkrouterc6u
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.15.0
|
|
4
4
|
Summary: TP-Link Router API (supports also Mercusys Router)
|
|
5
5
|
Home-page: https://github.com/AlexandrErohin/TP-Link-Archer-C6U
|
|
6
6
|
Author: Alex Erohin
|
|
@@ -57,6 +57,7 @@ Python package for API access and management for TP-Link and Mercusys Routers. S
|
|
|
57
57
|
from tplinkrouterc6u import (
|
|
58
58
|
TplinkRouterProvider,
|
|
59
59
|
TplinkRouterV1_11,
|
|
60
|
+
TplinkRouterSG, # For routers like Archer BE3600, Archer BE230
|
|
60
61
|
TplinkRouter,
|
|
61
62
|
TplinkC1200Router,
|
|
62
63
|
TplinkC5400XRouter,
|
|
@@ -68,7 +69,7 @@ from tplinkrouterc6u import (
|
|
|
68
69
|
TPLinkVR400v2Client,
|
|
69
70
|
TPLinkEXClient, # Class for EX series routers which supports old firmwares with AES cipher CBC mode
|
|
70
71
|
TPLinkEXClientGCM, # Class for EX series routers which supports AES cipher GCM mode
|
|
71
|
-
TPLinkRClient,
|
|
72
|
+
TPLinkRClient, # For routers like TL-R470GP-AC
|
|
72
73
|
TPLinkXDRClient,
|
|
73
74
|
TPLinkDecoClient,
|
|
74
75
|
TplinkC80Router,
|
|
@@ -83,11 +84,11 @@ router = TplinkRouterProvider.get_client('http://192.168.0.1', 'password')
|
|
|
83
84
|
# You may use client directly like
|
|
84
85
|
# router = TplinkRouter('http://192.168.0.1', 'password')
|
|
85
86
|
# You may also pass username if it is different and a logger to log errors as
|
|
86
|
-
# router = TplinkRouter('http://192.168.0.1','password','admin2', Logger('test'))
|
|
87
|
+
# router = TplinkRouter('http://192.168.0.1','password','admin2', logger=Logger('test'))
|
|
87
88
|
# If you have the TP-link C5400X or similar, you can use the TplinkC5400XRouter class instead of the TplinkRouter class.
|
|
88
89
|
# Remember that the password for this router is different, here you need to use the web encrypted password.
|
|
89
90
|
# To get web encrypted password, read Web Encrypted Password section
|
|
90
|
-
# router = TplinkC5400XRouter('http://192.168.0.1','WebEncryptedPassword', logger
|
|
91
|
+
# router = TplinkC5400XRouter('http://192.168.0.1','WebEncryptedPassword', logger=Logger('test'))
|
|
91
92
|
|
|
92
93
|
try:
|
|
93
94
|
router.authorize() # authorizing
|
|
@@ -202,6 +203,10 @@ or you have TP-link C5400X or similar router you need to get web encrypted passw
|
|
|
202
203
|
| packets_received | total packets received | int, None |
|
|
203
204
|
| down_speed | download speed | int, None |
|
|
204
205
|
| up_speed | upload speed | int, None |
|
|
206
|
+
| tx_rate | transmit rate (Mbps) | int, None |
|
|
207
|
+
| rx_rate | receive rate (Mbps) | int, None |
|
|
208
|
+
| online_time | client online time (seconds) | float, None |
|
|
209
|
+
| traffic_usage | total traffic usage (bytes) | int, None |
|
|
205
210
|
| signal | Signal strength | int, None |
|
|
206
211
|
| active | Is active device | bool |
|
|
207
212
|
|
|
@@ -315,6 +320,7 @@ or you have TP-link C5400X or similar router you need to get web encrypted passw
|
|
|
315
320
|
- Archer A7 V5
|
|
316
321
|
- Archer A8 (1.0, 2.20)
|
|
317
322
|
- Archer A9 V6
|
|
323
|
+
- Archer A10 v1
|
|
318
324
|
- Archer A20 v1.0
|
|
319
325
|
- Archer AX10 v1.0
|
|
320
326
|
- Archer AX12 v1.0
|
|
@@ -344,10 +350,10 @@ or you have TP-link C5400X or similar router you need to get web encrypted passw
|
|
|
344
350
|
- Archer BE550 v1.0
|
|
345
351
|
- Archer BE800 v1.0
|
|
346
352
|
- Archer BE805 v1.0
|
|
347
|
-
- Archer BE3600
|
|
353
|
+
- Archer BE3600 (v1.0, v1.6)
|
|
348
354
|
- Archer C1200 (v1.0, v2.0)
|
|
349
355
|
- Archer C2300 (v1.0, v2.0)
|
|
350
|
-
- Archer C3200
|
|
356
|
+
- Archer C3200 v1
|
|
351
357
|
- Archer C6 (v2.0, v3.0, v3.20, 4.0)
|
|
352
358
|
- Archer C6U v1.0
|
|
353
359
|
- Archer C7 (v4.0, v5.0)
|
|
@@ -360,7 +366,7 @@ or you have TP-link C5400X or similar router you need to get web encrypted passw
|
|
|
360
366
|
- Archer MR200 (v2, v5, v5.3, v6.0)
|
|
361
367
|
- Archer MR550 v1
|
|
362
368
|
- Archer MR600 (v1, v2, v3)
|
|
363
|
-
- Archer NX200 v2.0
|
|
369
|
+
- Archer NX200 (v1.0, v2.0)
|
|
364
370
|
- Archer VR400 (v2, v3)
|
|
365
371
|
- Archer VR600 v3
|
|
366
372
|
- Archer VR900v
|
|
@@ -387,6 +393,7 @@ or you have TP-link C5400X or similar router you need to get web encrypted passw
|
|
|
387
393
|
- HX510 v1.0
|
|
388
394
|
- M8550 v1
|
|
389
395
|
- NE200-Outdoor v1.0
|
|
396
|
+
- NE211-Outdoor v1.0
|
|
390
397
|
- NX510v v1.0
|
|
391
398
|
- NX600 v2.0
|
|
392
399
|
- TD-W9960 (v1, V1.20)
|
|
@@ -1,46 +1,48 @@
|
|
|
1
1
|
test/__init__.py,sha256=McQmUjeN3AwmwdS6QNfwGXXE77OKoPK852I2BM9XsrU,210
|
|
2
2
|
test/test_client_C3200.py,sha256=jQoGq3yasoOc2Da37RkZ8uT0uyFrKfX2OGGy7hq02A0,20932
|
|
3
3
|
test/test_client_c1200.py,sha256=Sl-85JGqINNg-ckBZCIVqY0CC-V1UOc-yiIUljtePRM,7582
|
|
4
|
-
test/test_client_c6u.py,sha256=
|
|
4
|
+
test/test_client_c6u.py,sha256=ZdIMNpnLrwH6a4E6Myb8tz6S0qtiVOJ9ye4mmn-vVCI,46579
|
|
5
5
|
test/test_client_c6u_v1_11.py,sha256=FkpDRYk-LKC_YFNyrDUZUm7_x89REMnl4zquiEtyFB4,3343
|
|
6
6
|
test/test_client_c80.py,sha256=RY_1SgRVcQQdN9h0_IXA0YW4_0flEB_uel05QvDDfws,42359
|
|
7
7
|
test/test_client_deco.py,sha256=YPLKRD8GoyDYHfRgdXvCk8iVNw8zdMJW-AHVnNbpdTM,31719
|
|
8
|
-
test/test_client_ex.py,sha256=
|
|
8
|
+
test/test_client_ex.py,sha256=IPPqFllDFCn2-StsV3cv3GuIDgwXM75x4wk8dSXN8eA,45556
|
|
9
9
|
test/test_client_mr.py,sha256=HYn39wynxeWKyWLGKpnBrutnb1Yq6k1tp6_vH-L_UKQ,33839
|
|
10
10
|
test/test_client_mr_200.py,sha256=86yANn5SUhVW6Uc5q5s_aTNL7tDnREeXk378G61v_TM,1186
|
|
11
11
|
test/test_client_r.py,sha256=AMZklBTLDmnlluNu8hyJfty-1lnN9YReIT522D2Bf9c,20565
|
|
12
12
|
test/test_client_re330.py,sha256=MgefuvOzfZtZOujrcOsjiTDiGEAujfeFXshcq7gn32Q,17044
|
|
13
|
+
test/test_client_sg.py,sha256=p8YlashUz370XFEY40PqiXE7CauEbUHEroTUjRIzjS8,7722
|
|
13
14
|
test/test_client_vr400v2.py,sha256=J1MFUQKGX0czhYS2s8q1Fa8-aKAZ9RfWb0rE_yAxXmg,1813
|
|
14
15
|
test/test_client_wdr.py,sha256=0ZnRNP57MbuMv2cxFS8iIoVyv8Q6gtY0Q03gtHp9AWY,13492
|
|
15
16
|
test/test_client_xdr.py,sha256=o0d1mq5ev1wWcs7FvwYGMUKDSVZJREETZk__7Al5EtI,23640
|
|
16
|
-
tplinkrouterc6u/__init__.py,sha256=
|
|
17
|
+
tplinkrouterc6u/__init__.py,sha256=ekYOi1CJEH-OvqC7OgsYPUTkwt0kokD3xGVhSLMUh6A,1472
|
|
17
18
|
tplinkrouterc6u/client_abstract.py,sha256=3UYzmll774S_Gb5E0FTVO_rI3-XFM7PSklg1-V-2jls,1419
|
|
18
|
-
tplinkrouterc6u/provider.py,sha256=
|
|
19
|
+
tplinkrouterc6u/provider.py,sha256=TsKKhMSrE6ZhTJL3qf8T34lw3-t68KmHdp6EvzFaUwc,3316
|
|
19
20
|
tplinkrouterc6u/client/__init__.py,sha256=KBy3fmtA9wgyFrb0Urh2x4CkKtWVnESdp-vxmuOvq0k,27
|
|
20
21
|
tplinkrouterc6u/client/c1200.py,sha256=4XEYidEGmVIJk0YQLvmTnd0Gqa7glH2gUWvjreHpWrk,3178
|
|
21
22
|
tplinkrouterc6u/client/c3200.py,sha256=GjaOjcVoKlRiSjn19d1rEIfZbIP_6SWsqDlsd_BbC7I,7057
|
|
22
23
|
tplinkrouterc6u/client/c5400x.py,sha256=ID9jC-kLUBBeETvOh8cxyQpKmJBIzdwNYR03DmvMN0s,4289
|
|
23
|
-
tplinkrouterc6u/client/c6u.py,sha256=
|
|
24
|
+
tplinkrouterc6u/client/c6u.py,sha256=Owf-nZmqVK4lvP86_oYbs5kBdJtlfew_r8iHrasSv1Q,24130
|
|
24
25
|
tplinkrouterc6u/client/c80.py,sha256=efE0DEjEfzRFr35fjKA_hsv9YaWy_2dgLAaurDM-WQk,17665
|
|
25
26
|
tplinkrouterc6u/client/deco.py,sha256=cpKRggKD2RvSmMZuD6tzsZmehAUCU9oLiTTHcZBW81Y,8898
|
|
26
|
-
tplinkrouterc6u/client/ex.py,sha256=
|
|
27
|
+
tplinkrouterc6u/client/ex.py,sha256=ujUzhrRyzbV2vTIbDqiiuWybOOWVNjHHD8VnkGqbVjA,16556
|
|
27
28
|
tplinkrouterc6u/client/mr.py,sha256=Yh7U0nWa_l5HKrUM5fDGewQlxHlyK-JcoPFXmSDgog4,29606
|
|
28
|
-
tplinkrouterc6u/client/mr200.py,sha256=
|
|
29
|
+
tplinkrouterc6u/client/mr200.py,sha256=vqBnx8JzdegXzOBfyPo_AjsPpbYZHxzNSeW0cqBmTqY,5941
|
|
29
30
|
tplinkrouterc6u/client/mr6400v7.py,sha256=PR7dk4Q0OMqMwnuExpHFjanCYAcZ2gjnJ0XoMV-ex8I,2407
|
|
30
31
|
tplinkrouterc6u/client/r.py,sha256=H-qArD60gasT07pEeY48n0_c6-yUbAKL7IRmQtr6jXk,7632
|
|
31
32
|
tplinkrouterc6u/client/re330.py,sha256=9Wj4VpYJbVwZJUh9s3magdeL3Jl-B7qyrWfrVBxRk4A,17465
|
|
33
|
+
tplinkrouterc6u/client/sg.py,sha256=WPxqxST84Dh-iO-V0oPRpehn2D21YwAq4r_4mEAFSv8,11353
|
|
32
34
|
tplinkrouterc6u/client/vr.py,sha256=7Tbu0IrWtr4HHtyrnLFXEJi1QctzhilciL7agtwQ0R8,5025
|
|
33
35
|
tplinkrouterc6u/client/vr400v2.py,sha256=ZgQ3w4s9cqhAYgq-xr7l64v1pCT3izdw6amG9XfM0cA,4208
|
|
34
36
|
tplinkrouterc6u/client/wdr.py,sha256=i54PEifjhfOScDpgNBXygw9U4bfsVtle846_YjnDoBs,21679
|
|
35
37
|
tplinkrouterc6u/client/xdr.py,sha256=CQN5h2f2oUuwjbtUeOjisBT8K1a72snIDxsWMc_qGik,10917
|
|
36
38
|
tplinkrouterc6u/common/__init__.py,sha256=pCTvVZ9CAwgb7MxRnLx0y1rI0sTKSwT24FfxWfQXeTM,33
|
|
37
|
-
tplinkrouterc6u/common/dataclass.py,sha256=
|
|
39
|
+
tplinkrouterc6u/common/dataclass.py,sha256=9l_OepmU2JU8SJxYVOPvO_9PMu5nofKEqeFTSHB-s2c,7885
|
|
38
40
|
tplinkrouterc6u/common/encryption.py,sha256=EWfgGafOz0YgPilBndVaupnjw6JrzhVBdZkBy3oWhj0,10229
|
|
39
41
|
tplinkrouterc6u/common/exception.py,sha256=_0G8ZvW5__CsGifHrsZeULdl8c6EUD071sDCQsQgrHY,140
|
|
40
42
|
tplinkrouterc6u/common/helper.py,sha256=23b04fk9HuVinrZXMCS5R1rmF8uZ7eM-Cdnp7Br9NR0,572
|
|
41
43
|
tplinkrouterc6u/common/package_enum.py,sha256=CMHVSgk4RSZyFoPi3499-sJDYg-nfnyJbz1iArFU9Hw,1644
|
|
42
|
-
tplinkrouterc6u-5.
|
|
43
|
-
tplinkrouterc6u-5.
|
|
44
|
-
tplinkrouterc6u-5.
|
|
45
|
-
tplinkrouterc6u-5.
|
|
46
|
-
tplinkrouterc6u-5.
|
|
44
|
+
tplinkrouterc6u-5.15.0.dist-info/licenses/LICENSE,sha256=YF6QR6Vjxcg5b_sYIyqkME7FZYau5TfEUGTG-0JeRK0,35129
|
|
45
|
+
tplinkrouterc6u-5.15.0.dist-info/METADATA,sha256=9EEpcuq5euG0Beh4eGSldAafxtnnXob-bNX9bPnNEfY,18229
|
|
46
|
+
tplinkrouterc6u-5.15.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
47
|
+
tplinkrouterc6u-5.15.0.dist-info/top_level.txt,sha256=1iSCCIueqgEkrTxtQ-jiHe99jAB10zqrVdBcwvNfe_M,21
|
|
48
|
+
tplinkrouterc6u-5.15.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|