├── .gitignore ├── README.md ├── setup.py └── smrt ├── __init__.py ├── discovery.py ├── network.py ├── operations.py ├── protocol.py └── smrt.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | dumps 3 | __pycache__ 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | A utility to configure your TP-Link Easy Smart Switch 3 | on Linux or Mac OS X. This tool is written in Python. 4 | 5 | Supposedly supported switches: 6 | 7 | * TL-SG105E (tested) 8 | * TL-SG108E (tested) 9 | * TL-SG108PE 10 | * TL-SG1016DE 11 | * TL-SG1024DE 12 | 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | try: 4 | from setuptools import setup 5 | except ImportError: 6 | from distutils.core import setup 7 | 8 | try: 9 | import pypandoc 10 | # for PyPI: Removing images with relative paths and their descriptions: 11 | import re 12 | LDESC = open('README.md', 'r').read() 13 | matches = re.findall(r'\n\n(.*(\n.+)*:\n\n!\[.*\]\((.*\))(\n\n)?)', LDESC) 14 | for match in matches: 15 | text, _, link, _ = match 16 | if text.startswith('http://'): continue 17 | LDESC = LDESC.replace(text, '') 18 | # Converting to rst 19 | LDESC = pypandoc.convert(LDESC, 'rst', format='md') 20 | except (ImportError, IOError, RuntimeError): 21 | LDESC = '' 22 | 23 | setup(name='smrt', 24 | version = '0.1.dev', 25 | description = 'Python package and software for the TP-Link TL-SG105E and TL-SG108E smart switches', 26 | long_description = LDESC, 27 | author = 'Philipp Klaus', 28 | author_email = 'philipp.l.klaus@web.de', 29 | url = 'https://github.com/pklaus/smrt', 30 | license = 'GPL', 31 | #packages = ['smrt', 'smrt.discovery', 'smrt.operations', 'smrt.protocol', 'smrt.smrt'], 32 | packages = ['smrt'], 33 | entry_points = { 34 | 'console_scripts': [ 35 | 'smrt.cli = smrt.smrt:main', 36 | 'smrt.discovery = smrt.discovery:main', 37 | ], 38 | }, 39 | include_package_data = True, 40 | zip_safe = True, 41 | platforms = 'any', 42 | install_requires = ['netifaces'], 43 | extras_require = { 44 | #'savescreen': ["Pillow",], 45 | }, 46 | #package_data = { 47 | # '': ['resources/*.png'], 48 | #}, 49 | keywords = 'TP-Link TL-SG105E TL-SG108E', 50 | classifiers = [ 51 | 'Development Status :: 4 - Beta', 52 | 'Operating System :: OS Independent', 53 | 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', 54 | 'Programming Language :: Python', 55 | 'Programming Language :: Python :: 3', 56 | 'Topic :: Scientific/Engineering :: Visualization', 57 | 'Topic :: Scientific/Engineering', 58 | 'Topic :: System :: Hardware :: Hardware Drivers', 59 | 'Intended Audience :: Science/Research', 60 | ] 61 | ) 62 | 63 | 64 | -------------------------------------------------------------------------------- /smrt/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | class SmrtPyException(Exception): pass 3 | 4 | class ConnectionException(SmrtPyException): pass 5 | 6 | class IncompatiblePlatformException(SmrtPyException): pass 7 | 8 | #from . import protocol 9 | #from . import operations 10 | #from . import network 11 | #from . import discovery 12 | #from . import smrt 13 | -------------------------------------------------------------------------------- /smrt/discovery.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys, socket, random, logging, platform, argparse 4 | 5 | import netifaces 6 | 7 | from . import IncompatiblePlatformException 8 | from .protocol import * 9 | from .network import * 10 | 11 | DISCOVERY_TIMEOUT = 0.5 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | def discover_switches(interfaces='all'): 16 | if interfaces == 'all': 17 | interfaces = netifaces.interfaces() 18 | settings = [] 19 | for iface in interfaces: 20 | addrs = netifaces.ifaddresses(iface) 21 | if netifaces.AF_INET not in addrs: continue 22 | if netifaces.AF_LINK not in addrs: continue 23 | assert len(addrs[netifaces.AF_LINK]) == 1 24 | mac = addrs[netifaces.AF_LINK][0]['addr'] 25 | for addr in addrs[netifaces.AF_INET]: 26 | if 'broadcast' not in addr or 'addr' not in addr: continue 27 | settings.append((iface, addr['addr'], mac, addr['broadcast'])) 28 | 29 | for iface, ip, mac, broadcast in settings: 30 | logger.warning((iface, ip, mac, broadcast)) 31 | sequence_id = random.randint(0, 1000) 32 | header = DEFAULT_HEADER.copy() 33 | header.update({ 34 | 'sequence_id': sequence_id, 35 | 'host_mac': bytes(int(byte, 16) for byte in mac.split(':')), 36 | }) 37 | packet = assemble_packet(header, {}) 38 | packet = encode(packet) 39 | 40 | if platform.system().lower() == 'darwin': 41 | rs = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 42 | rs.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 43 | rs.bind(('', UDP_RECEIVE_FROM_PORT)) 44 | rs.settimeout(DISCOVERY_TIMEOUT) 45 | elif platform.system().lower() == 'linux': 46 | rs = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 47 | rs.bind((BROADCAST_ADDR, UDP_RECEIVE_FROM_PORT)) 48 | #rs.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 49 | rs.settimeout(DISCOVERY_TIMEOUT) 50 | else: 51 | raise IncompatiblePlatformException() 52 | 53 | ss = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 54 | ss.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 55 | ss.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 56 | ss.bind((ip, UDP_RECEIVE_FROM_PORT)) 57 | ss.sendto(packet, (BROADCAST_ADDR, UDP_SEND_TO_PORT)) 58 | ss.close() 59 | 60 | 61 | 62 | while True: 63 | try: 64 | data, addr = rs.recvfrom(1500) 65 | except: 66 | break 67 | data = decode(data) 68 | header, payload = split(data) 69 | payload = interpret_payload(payload) 70 | context = {'iface': iface, 'ip': ip, 'mac': mac, 'broadcast': broadcast} 71 | yield context, header, payload 72 | rs.close() 73 | 74 | def main(): 75 | if platform.system().lower() not in ('darwin', 'linux'): 76 | sys.stderr.write('Discovery is not implemented for the platform %s' % platform.system()) 77 | sys.exit(4) 78 | parser = argparse.ArgumentParser() 79 | parser.add_argument('interfaces', metavar='INTERFACE', nargs='*', default='all') 80 | args = parser.parse_args() 81 | logging.basicConfig(level=logging.WARNING) 82 | switches = discover_switches(interfaces=args.interfaces) 83 | for context, header, payload in switches: 84 | get = lambda kind: get_payload_item_value(payload, kind) 85 | fmt = "Found a switch: Host: (Interface: {iface:8s} IP: {host_ip} Broadcast: {broadcast})\n" 86 | fmt += " Switch: (Kind: {kind:12s} MAC Address: {mac} IP Address: {switch_ip})" 87 | print(fmt.format(iface=context['iface'], host_ip=context['ip'], broadcast=context['broadcast'], 88 | kind=get('type'), mac=get('mac'), switch_ip=get('ip_addr'))) 89 | 90 | if __name__ == "__main__": main() 91 | -------------------------------------------------------------------------------- /smrt/network.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import socket, random, logging 4 | 5 | from .protocol import * 6 | from .operations import * 7 | 8 | BROADCAST_ADDR = "255.255.255.255" 9 | UDP_SEND_TO_PORT = 29808 10 | UDP_RECEIVE_FROM_PORT = 29809 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | def mac_to_bytes(mac): 15 | return bytes(int(byte, 16) for byte in mac.split(':')) 16 | 17 | def mac_to_str(mac): 18 | return ':'.join('{:02X}'.format(byte) for byte in mac) 19 | 20 | class SwitchConversation(object): 21 | 22 | def __init__(self, switch_mac, host_mac, ip_address): 23 | 24 | self.switch_mac = switch_mac 25 | self.host_mac = host_mac 26 | self.ip_address = ip_address 27 | 28 | self.sequence_id = random.randint(0, 1000) 29 | 30 | header = DEFAULT_HEADER.copy() 31 | header.update({ 32 | 'sequence_id': self.sequence_id, 33 | 'host_mac': mac_to_bytes(host_mac), 34 | 'switch_mac': mac_to_bytes(switch_mac), 35 | }) 36 | self.header = header 37 | 38 | # Sending socket 39 | ss = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 40 | ss.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 41 | ss.bind((ip_address, UDP_RECEIVE_FROM_PORT)) 42 | 43 | # Receiving socket 44 | rs = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 45 | rs.bind((BROADCAST_ADDR, UDP_RECEIVE_FROM_PORT)) 46 | rs.settimeout(0.4) 47 | 48 | self.ss, self.rs = ss, rs 49 | 50 | def send(self, op_code, payload): 51 | self.sequence_id = (self.sequence_id + 1) % 1000 52 | 53 | header = self.header 54 | header.update({ 55 | 'sequence_id': self.sequence_id, 56 | 'op_code': op_code, 57 | }) 58 | 59 | logger.debug('Sending Header: ' + str(header)) 60 | logger.debug('Sending Payload: ' + str(payload)) 61 | packet = assemble_packet(header, payload) 62 | packet = encode(packet) 63 | 64 | self.ss.sendto(packet, (BROADCAST_ADDR, UDP_SEND_TO_PORT)) 65 | 66 | def receive(self): 67 | try: 68 | fragment_offset = 1 69 | total_payload = b'' 70 | while fragment_offset: 71 | data, addr = self.rs.recvfrom(1500) 72 | data = decode(data) 73 | header, payload = split(data) 74 | header, payload = interpret_header(header), interpret_payload(payload) 75 | fragment_offset = header['fragment_offset'] 76 | logger.debug('Received Header: ' + str(header)) 77 | logger.debug('Received Payload: ' + str(payload)) 78 | self.header['token_id'] = header['token_id'] 79 | total_payload += payload 80 | return header, payload 81 | except: 82 | print("no response") 83 | raise ConnectionProblem() 84 | return {}, {} 85 | 86 | def query(self, op_code, payload): 87 | self.send(op_code, payload) 88 | header, payload = self.receive() 89 | sequence_kind = get_sequence_kind((op_code, header['op_code'])) 90 | logger.debug('Sequence kind: ' + sequence_kind) 91 | return header, payload 92 | 93 | def login(self, username, password): 94 | self.query(GET, get_token_id_payload()) 95 | self.query(LOGIN, login_payload(username, password)) 96 | 97 | -------------------------------------------------------------------------------- /smrt/operations.py: -------------------------------------------------------------------------------- 1 | 2 | from .protocol import * 3 | 4 | def query_port_mirror_payload(): 5 | return { get_id('port_mirror'): b'' } 6 | 7 | def login_payload(username, password): 8 | username = username.encode('utf-8') + b'\x00' 9 | password = password.encode('utf-8') + b'\x00' 10 | return { get_id('username'): username, get_id('password'): password } 11 | 12 | def get_token_id_payload(): 13 | return { get_id('get_token_id'): b'' } 14 | -------------------------------------------------------------------------------- /smrt/protocol.py: -------------------------------------------------------------------------------- 1 | """ 2 | TP-Link TL-SG105E and TL-SG108E switces 3 | UDP configuration protocol 4 | 5 | Wireshark filter: 6 | ip.addr ==192.168.178.250 || ip.addr ==192.168.178.254 && udp.port == 29808 7 | """ 8 | 9 | import struct 10 | from ipaddress import ip_address 11 | from collections import OrderedDict 12 | 13 | HEADER_LEN = 32 14 | PACKET_END = b'\xff\xff\x00\x00' 15 | 16 | DEFAULT_HEADER = { 17 | 'fragment_offset': 0, 18 | 'sequence_id': 0, 19 | 'checksum': 0, 20 | 'switch_mac': b'\x00\x00\x00\x00\x00\x00', 21 | 'check_length': 0, 22 | 'token_id': 0, 23 | 'op_code': 0, 24 | 'error_code': 0, 25 | 'host_mac': b'\x00\x00\x00\x00\x00\x00', 26 | 'version': 1, 27 | 'flag': 0 28 | } 29 | 30 | header_structure = { 31 | 'fmt': '!bb6s6shihhhhi', 32 | 'designators': [ 33 | 'version', 34 | 'op_code', 35 | 'switch_mac', 36 | 'host_mac', 37 | 'sequence_id', 38 | 'error_code', 39 | 'check_length', 40 | 'fragment_offset', 41 | 'flag', 42 | 'token_id', 43 | 'checksum', 44 | ] 45 | } 46 | 47 | DISCOVERY = 0 48 | GET = 1 49 | SET = 2 50 | LOGIN = 3 51 | RETURN = 4 52 | READ5 = 5 53 | 54 | op_codes = { 55 | DISCOVERY: 'DISCOVERY', 56 | GET: 'GET', 57 | SET: 'SET', 58 | LOGIN: 'LOGIN', 59 | RETURN: 'RETURN', 60 | READ5: 'READ5' 61 | } 62 | 63 | sequences = { 64 | # name ->switch switch-> 65 | 'login/change': (LOGIN, RETURN), 66 | 'query': (GET, SET), 67 | 'discover': (DISCOVERY, SET), 68 | } 69 | 70 | receive_ids = { 71 | 1: ['*s', 'str', 'type'], 72 | 2: ['*s', 'str', 'hostname'], 73 | 3: ['6s', 'hex', 'mac'], 74 | 4: ['4s', 'ip', 'ip_addr'], 75 | 5: ['4s', 'ip', 'ip_mask'], 76 | 6: ['4s', 'ip', 'gateway'], 77 | 7: ['*s', 'str', 'firmware_version'], 78 | 8: ['*s', 'str', 'hardware_version'], 79 | 9: ['?', 'bool', 'dhcp'], 80 | 10: ['b', 'dec', 'num_ports'], 81 | 512: ['*s', 'str', 'username'], 82 | 514: ['*s', 'str', 'password'], 83 | 2304: ['a', 'action','save'], 84 | 2305: ['a', 'action','get_token_id'], 85 | 4352: ['?', 'bool', 'igmp_snooping'], 86 | 4096: ['*s', 'hex', 'port_settings'], 87 | 4608: ['5s', 'hex', 'port_trunk'], 88 | 8192: ['2s', 'hex', 'mtu_vlan'], 89 | 8704: ['?', 'hex', '802.1q vlan enabled'], 90 | 8705: ['*s', 'hex', '802.1q vlan'], 91 | 8706: ['*s', 'hex', '802.1q vlan pvid'], 92 | 12288: ['?', 'bool', 'QoS Basic 1'], 93 | 12289: ['2s', 'hex', 'QoS Basic 2'], 94 | 16640: ['10s','hex', 'port_mirror'], 95 | 16384: ['*s', 'hex', 'port_statistics'], 96 | 17152: ['?', 'bool', 'loop_prevention'], 97 | } 98 | 99 | def get_sequence_kind(sequence): 100 | for key, value in sequences.items(): 101 | if value == sequence: return key 102 | return 'unknown' 103 | 104 | def get_id(name): 105 | for key, value in receive_ids.items(): 106 | if value[2] == name: return key 107 | raise Exception() 108 | 109 | def hex_readable(bts): 110 | return ':'.join(['{:02X}'.format(byte) for byte in bts]) 111 | 112 | def payload_str(payload): 113 | ret = '' 114 | if type(payload) == bytes: 115 | items = interpret_payload(payload) 116 | else: 117 | items = payload 118 | for item_id in items.keys(): 119 | value = items[item_id] 120 | try: 121 | value = interpret_value(value, receive_ids[item_id][1]) 122 | except: 123 | pass 124 | if item_id not in receive_ids: 125 | ret += 'Unknown code: %s (content: %s)\n' % (item_id, value) 126 | continue 127 | struct_fmt = receive_ids[item_id][0] 128 | kind = receive_ids[item_id][1] 129 | name = receive_ids[item_id][2] 130 | fmt = '{name}: {value} (id: {id}, kind: {kind})\n' 131 | ret += fmt.format(name=name, value=value, id=item_id, kind=kind) 132 | return ret 133 | 134 | def decode(data): 135 | data = list(data) 136 | key = [ 191, 155, 227, 202, 99, 162, 79, 104, 49, 18, 190, 164, 30, 137 | 76, 189, 131, 23, 52, 86, 106, 207, 125, 126, 169, 196, 28, 172, 58, 138 | 188, 132, 160, 3, 36, 120, 144, 168, 12, 231, 116, 44, 41, 97, 108, 139 | 213, 42, 198, 32, 148, 218, 107, 247, 112, 204, 14, 66, 68, 91, 224, 140 | 206, 235, 33, 130, 203, 178, 1, 134, 199, 78, 249, 123, 7, 145, 73, 141 | 208, 209, 100, 74, 115, 72, 118, 8, 22, 243, 147, 64, 96, 5, 87, 60, 142 | 113, 233, 152, 31, 219, 143, 174, 232, 153, 245, 158, 254, 70, 170, 143 | 75, 77, 215, 211, 59, 71, 133, 214, 157, 151, 6, 46, 81, 94, 136, 144 | 166, 210, 4, 43, 241, 29, 223, 176, 67, 63, 186, 137, 129, 40, 248, 145 | 255, 55, 15, 62, 183, 222, 105, 236, 197, 127, 54, 179, 194, 229, 146 | 185, 37, 90, 237, 184, 25, 156, 173, 26, 187, 220, 2, 225, 0, 240, 147 | 50, 251, 212, 253, 167, 17, 193, 205, 177, 21, 181, 246, 82, 226, 148 | 38, 101, 163, 182, 242, 92, 20, 11, 95, 13, 230, 16, 121, 124, 109, 149 | 195, 117, 39, 98, 239, 84, 56, 139, 161, 47, 201, 51, 135, 250, 10, 150 | 19, 150, 45, 111, 27, 24, 142, 80, 85, 83, 234, 138, 216, 57, 93, 151 | 65, 154, 141, 122, 34, 140, 128, 238, 88, 89, 9, 146, 171, 149, 53, 152 | 102, 61, 114, 69, 217, 175, 103, 228, 35, 180, 252, 200, 192, 165, 153 | 159, 221, 244, 110, 119, 48] 154 | length = len(data) 155 | #s = [bytes([char]) for char in key] 156 | s = key 157 | i, j = 0, 0 158 | for k in range(length): 159 | i = (k + 1) % 256; 160 | j = (j + s[i]) % 256; 161 | s[i], s[j] = s[j], s[i] 162 | data[k] = data[k] ^ s[(s[i] + s[j]) % 256]; 163 | return bytes(data) 164 | 165 | encode = decode 166 | 167 | def split(data): 168 | assert len(data) >= HEADER_LEN + len(PACKET_END) 169 | assert data.endswith(PACKET_END) 170 | return (data[0:HEADER_LEN], data[HEADER_LEN:]) 171 | 172 | def interpret_header(header): 173 | names = header_structure['designators'] 174 | vals = struct.unpack(header_structure['fmt'], header) 175 | return dict(zip(names, vals)) 176 | 177 | def interpret_payload(payload): 178 | results = OrderedDict() 179 | while len(payload) > len(PACKET_END): 180 | dtype, dlen = struct.unpack('!hh', payload[0:4]) 181 | data = payload[4:4+dlen] 182 | payload = payload[4+dlen:] 183 | results[dtype] = data 184 | return results 185 | 186 | def assemble_packet(header, payload): 187 | payload_bytes = b'' 188 | for dtype, value in payload.items(): 189 | payload_bytes += struct.pack('!hh', dtype, len(value)) 190 | payload_bytes += value 191 | header['check_length'] = HEADER_LEN + len(payload_bytes) + len(PACKET_END) 192 | header = tuple(header[part] for part in header_structure['designators']) 193 | header_bytes = struct.pack(header_structure['fmt'], *header) 194 | return header_bytes + payload_bytes + PACKET_END 195 | 196 | def interpret_value(value, kind): 197 | if kind == 'str': 198 | value = value.split(b'\x00', 1)[0].decode('ascii') 199 | elif kind == 'ip': 200 | value = ip_address(value) 201 | elif kind == 'hex': 202 | value = ':'.join(['{:02X}'.format(byte) for byte in value]) 203 | elif kind == 'action': 204 | value = "n/a" 205 | elif kind == 'dec': 206 | value = int(''.join('%02X' % byte for byte in value), 16) 207 | elif kind == 'bool': 208 | if len(value) == 0: pass 209 | elif len(value) == 1: value = value[0] > 0 210 | else: raise AssertionError('boolean should be one byte long') 211 | return value 212 | 213 | def get_payload_item_context(items, name_key): 214 | hits = [key for key in items.keys() if receive_ids[key][2] == name_key] 215 | assert len(hits) == 1 216 | item_id = hits[0] 217 | 218 | kind = receive_ids[item_id][1] 219 | raw_value = items[item_id] 220 | value = interpret_value(raw_value, kind) 221 | 222 | return { 223 | 'id': item_id, 224 | 'struct_fmt': receive_ids[item_id][0], 225 | 'kind': kind, 226 | 'name': receive_ids[item_id][2], 227 | 'value': value, 228 | 'raw_value': raw_value, 229 | } 230 | 231 | def get_payload_item_value(items, name_key): 232 | context = get_payload_item_context(items, name_key) 233 | return context['value'] 234 | -------------------------------------------------------------------------------- /smrt/smrt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import socket, time, random, argparse, logging 4 | 5 | from .protocol import * 6 | from .network import SwitchConversation 7 | from .operations import * 8 | 9 | def loglevel(x): 10 | try: 11 | return getattr(logging, x.upper()) 12 | except AttributeError: 13 | raise argparse.ArgumentError('Select a proper loglevel') 14 | 15 | def main(): 16 | logger = logging.getLogger(__name__) 17 | parser = argparse.ArgumentParser() 18 | parser.add_argument('--configfile', '-c') 19 | parser.add_argument('--switch-mac', '-s') 20 | parser.add_argument('--host-mac', ) 21 | parser.add_argument('--ip-address', '-i') 22 | parser.add_argument('--username', '-u') 23 | parser.add_argument('--password', '-p') 24 | parser.add_argument('--loglevel', '-l', type=loglevel, default='INFO') 25 | parser.add_argument('action') 26 | args = parser.parse_args() 27 | 28 | logging.basicConfig(level=args.loglevel) 29 | 30 | switch_mac = args.switch_mac 31 | host_mac = args.host_mac 32 | ip_address = args.ip_address 33 | 34 | sc = SwitchConversation(switch_mac, host_mac, ip_address) 35 | 36 | sc.login(args.username, args.password) 37 | 38 | if args.action == 'query_port_mirror': 39 | header, payload = sc.query(GET, query_port_mirror_payload()) 40 | print(payload[16640]) 41 | #elif args.action == 'status': 42 | # header, payload = sc.query(GET, query_port_mirror_payload()) 43 | 44 | if __name__ == "__main__": 45 | main() 46 | --------------------------------------------------------------------------------