From 2e874307ed5a6cabbec25542785e8b35550fb81f Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Tue, 29 Jan 2019 08:35:10 -0500 Subject: [PATCH 01/34] WIP: MQTT --- .gitignore | 1 + Pipfile | 6 +-- bumper/mqttserver.py | 92 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 bumper/mqttserver.py diff --git a/.gitignore b/.gitignore index 6357b3d..fca03ff 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__ Pipfile.lock /bumper.sublime-project /bumper.sublime-workspace +.vscode/ \ No newline at end of file diff --git a/Pipfile b/Pipfile index def16e1..cb8599b 100644 --- a/Pipfile +++ b/Pipfile @@ -1,13 +1,9 @@ [[source]] - url = "https://pypi.python.org/simple" verify_ssl = true name = "pypi" - [packages] - - +hbmqtt = "*" [dev-packages] - diff --git a/bumper/mqttserver.py b/bumper/mqttserver.py new file mode 100644 index 0000000..34b6c84 --- /dev/null +++ b/bumper/mqttserver.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 + +import logging +import asyncio +import os +from hbmqtt.broker import Broker +import hbmqtt + +default_config = { + 'listeners': { + 'default': { + 'type': 'tcp', + 'bind': '0.0.0.0:1883', + }, + 'tls1': { + 'bind': '0.0.0.0:8883', + 'ssl': 'on', + #'cafile': '/some/cafile', + #'capath': '/some/folder', + #'capath': 'certificate data', + 'certfile': './certs/cert.pem', + 'keyfile': './certs/key.pem', + }, + }, + 'sys_interval': 10, + 'auth': { + 'allow-anonymous': True, + 'password-file': os.path.join(os.path.dirname(os.path.realpath(__file__)), "passwd"), + 'plugins': [ + 'VacBotAuth' + ] + }, + 'topic-check': { + 'enabled': False + } +} + +class BaseAuthPlugin: + def __init__(self, context): + self.context = context + try: + self.auth_config = self.context.config['auth'] + except KeyError: + self.context.logger.warning("'auth' section not found in context configuration") + + def authenticate(self, *args, **kwargs): + if not self.auth_config: + # auth config section not found + self.context.logger.warning("'auth' section not found in context configuration") + return False + return True + +class VacBotAuth(BaseAuthPlugin): + def __init__(self, context): + super().__init__(context) + + @asyncio.coroutine + def authenticate(self, *args, **kwargs): + authenticated = super().authenticate(*args, **kwargs) + if authenticated: + allow_anonymous = self.auth_config.get('allow-anonymous', True) # allow anonymous by default + if allow_anonymous: + authenticated = True + self.context.logger.debug("Authentication success: config allows anonymous") + else: + try: + session = kwargs.get('session', None) + authenticated = True if session.username else False + if self.context.logger.isEnabledFor(logging.DEBUG): + if authenticated: + self.context.logger.debug("Authentication success: session has a non empty username") + else: + self.context.logger.debug("Authentication failure: session has an empty username") + except KeyError: + self.context.logger.warning("Session informations not available") + authenticated = False + return authenticated + +@asyncio.coroutine +def broker_coro(): + broker = hbmqtt.broker.Broker(config=default_config, plugin_namespace=".") + + print(broker.plugins_manager) + yield from broker.start() + + +if __name__ == '__main__': + formatter = "[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s" + logging.basicConfig(level=logging.DEBUG, format=formatter) + eloop = asyncio.get_event_loop().run_until_complete(broker_coro()) + + asyncio.get_event_loop().run_forever() \ No newline at end of file From 134d698eeb6a95f9e71b19759773953fab5564dc Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Wed, 30 Jan 2019 02:24:02 -0500 Subject: [PATCH 02/34] WIP: Moving along Getting closer to handling --- bumper.py | 14 +++-- bumper/__init__.py | 1 + bumper/confserver.py | 138 ++++++++++++++++++++++++++++++++++++------- bumper/mqttserver.py | 123 ++++++++++++++------------------------ 4 files changed, 172 insertions(+), 104 deletions(-) diff --git a/bumper.py b/bumper.py index 8dbf6dd..3a7bb61 100644 --- a/bumper.py +++ b/bumper.py @@ -4,14 +4,20 @@ import logging import bumper import sys, socket -logging.basicConfig(level=logging.INFO, +logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(levelname)-8s %(message)s') -conf_address = (socket.gethostbyname(socket.gethostname()), 8007) -xmpp_address = (socket.gethostbyname(socket.gethostname()), 5223) +#conf_address = (socket.gethostbyname(socket.gethostname()), 443) +conf_address = ("0.0.0.0", 443) +#xmpp_address = (socket.gethostbyname(socket.gethostname()), 5223) +xmpp_address = ("0.0.0.0", 5223) +#mqtt_address = (socket.gethostbyname(socket.gethostname()), 8883) +mqtt_address = ("0.0.0.0", 8883) # start conf server (async) -conf_server = bumper.ConfServer(conf_address, ssl=False, async=True) +conf_server = bumper.ConfServer(conf_address, usessl=True, run_async=True) +# start mqtt server (async) +mqtt_server = bumper.MQTTServer(mqtt_address, run_async=True) # start xmpp server (sync) xmpp_server = bumper.XMPPServer(xmpp_address) diff --git a/bumper/__init__.py b/bumper/__init__.py index 6ff2fd5..e75e9aa 100644 --- a/bumper/__init__.py +++ b/bumper/__init__.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 from .confserver import ConfServer +from .mqttserver import MQTTServer from .xmppserver import XMPPServer diff --git a/bumper/confserver.py b/bumper/confserver.py index dd31f8f..bf72faf 100644 --- a/bumper/confserver.py +++ b/bumper/confserver.py @@ -2,12 +2,14 @@ from http.server import HTTPServer from http.server import BaseHTTPRequestHandler +#from http.server import SimpleHTTPRequestHandler from http import HTTPStatus from threading import Thread import socket, logging, ssl, json, sys class RequestHandler(BaseHTTPRequestHandler): +#class RequestHandler(SimpleHTTPRequestHandler): def do_POST(self): try: self.protocol_version = 'HTTP/1.1' @@ -17,17 +19,44 @@ class RequestHandler(BaseHTTPRequestHandler): request_body = post_data.decode('utf-8') logging.debug("Request: " + request_body) json_body = json.loads(request_body) - todo = json_body['todo'] - if todo == 'FindBest': - service = json_body['service'] - if service == 'EcoMsgNew': - body = '{{"result":"ok","ip":"{}","port":5223}}'.format(socket.gethostbyname(socket.gethostname())) - elif service == 'EcoUpdate': - body = '{"result":"ok","ip":"47.88.66.164","port":8005}' - elif todo == 'loginByItToken': - body = "{{'todo': 'result', 'result': 'ok', 'userId': '{}', 'resource': '{}', 'token': '{}'}}".format(json_body['userId'], json_body['resource'], json_body['token']) - elif todo == 'GetDeviceList': - body = "{'todo': 'result', 'result': 'ok', 'devices': [{'did': '{}', 'name': '{}', 'class': '{}', 'resource': 'atom', 'nick': None, 'company': 'eco'}]}" + if "getProductIotMap" in str(self.path): + body = {"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]} + elif "notify_engine.do" in str(self.path): + body = {"ret":"ok"} + else: + todo = json_body['todo'] + if todo == 'FindBest': + service = json_body['service'] + if service == 'EcoMsgNew': + body = {"result":"ok","ip":socket.gethostbyname(socket.gethostname()),"port":5223} + elif service == 'EcoUpdate': + body = {"result":"ok","ip":"47.88.66.164","port":8005} + elif todo == 'loginByItToken': + body = { + "todo": "result", + "result": "ok", + "userId": json_body["userId"], + "resource": json_body["resource"], + "token": json_body["token"] + } + elif todo == 'GetDeviceList': + #Find a way to handle this automatically + #Maybe keep a list of devices from those that have checked into MQTT/XMPP/etc and return them + body = { + "devices": [ + { + "class": "tmpClass", + "company": "eco-ng", + "did": "tmpDeviceID", + "name": "tmpName", + "nick": "tmpNick", + "resource": "tmpResource" + } + ], + "result": "ok", + "todo": "result" + } + body = json.dumps(body) logging.debug("Response: " + body) body = body.encode() self.send_response(HTTPStatus.OK) @@ -37,7 +66,73 @@ class RequestHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) except Exception as e: - logging.error('ConfServer: {}'.format(e)) + logging.error('ConfServer POST: {}'.format(e)) + + def do_GET(self): + try: + self.protocol_version = 'HTTP/1.1' + logging.debug("Path: " + self.path) + + if "/login?" in str(self.path): + + #Could implement basic auth if you wanted, or just accept anything + #Next up parse the path for variables + body = { + "code": "0000", + "data": { + "accessToken": "temp_accesstoken", + "country": "us", + "email": "null@null.com", + "uid": "fuid_1", + "username": "fusername_1" + }, + "msg": "操作成功", + "time": 1547211900690 + } + + + elif "/getAuthCode?" in str(self.path): + body = { + "code": "0000", + "data": { + "authCode": "temp_authcode", + "ecovacsUid": "fuid_1" + }, + "msg": "操作成功", + "time": 1547211900808 + } + + elif "/checkVersion?" in str(self.path): + body = { + "code": "0000", + "data": { + "c": None, + "img": None, + "r": 0, + "t": None, + "u": None, + "ut": 0, + "v": None + }, + "msg": "操作成功", + "time": 1548829850462 + } + elif "/logout?" in str(self.path): + body = {"code": "0000","data": None,"msg": "操作成功","time": 1548830041460} + else: + body = {} + + body = json.dumps(body) + logging.debug("Response: " + body) + body = body.encode() + self.send_response(HTTPStatus.OK) + self.send_header('Content-Type', 'application/json; charset=utf-8') + self.send_header('Connection', 'keep-alive') + self.send_header('Content-Length', len(body)) + self.end_headers() + self.wfile.write(body) + except Exception as e: + logging.error('ConfServer GET: {}'.format(e)) class HTTPServerThread(HTTPServer, Thread): @@ -46,11 +141,12 @@ class HTTPServerThread(HTTPServer, Thread): self.server_address = server_address self.handler = RequestHandler self.exit_flag = False + HTTPServer.__init__(self, self.server_address, self.handler) + def handle_error(self, request, client_address): self.close_request(request) def run(self): - try: - HTTPServer.__init__(self, self.server_address, self.handler) + try: logging.info('ConfServer: listening on {}:{}'.format(self.server_address[0], self.server_address[1])) while not self.exit_flag: self.handle_request() @@ -65,13 +161,13 @@ class HTTPServerThread(HTTPServer, Thread): class ConfServer(): - def __init__(self, address, ssl=False, async=True): - try: - self.async = async - self.server = HTTPServerThread(address) - if ssl: + def __init__(self, address, usessl=False, run_async=True): + self.run_async = run_async + self.server = HTTPServerThread(address) + try: + if usessl: self.server.socket = ssl.wrap_socket(self.server.socket, keyfile='./certs/key.pem', certfile='./certs/cert.pem', server_side=True) - if self.async: + if self.run_async: self.server.start() else: try: @@ -83,6 +179,6 @@ class ConfServer(): def disconnect(self): logging.info('ConfServer: shutting down...') self.server.disconnect() - if(self.async): + if(self.run_async): self.server.join() logging.info('ConfServer: bye') diff --git a/bumper/mqttserver.py b/bumper/mqttserver.py index 34b6c84..441c19e 100644 --- a/bumper/mqttserver.py +++ b/bumper/mqttserver.py @@ -6,87 +6,52 @@ import os from hbmqtt.broker import Broker import hbmqtt -default_config = { - 'listeners': { - 'default': { - 'type': 'tcp', - 'bind': '0.0.0.0:1883', - }, - 'tls1': { - 'bind': '0.0.0.0:8883', - 'ssl': 'on', - #'cafile': '/some/cafile', - #'capath': '/some/folder', - #'capath': 'certificate data', - 'certfile': './certs/cert.pem', - 'keyfile': './certs/key.pem', - }, - }, - 'sys_interval': 10, - 'auth': { - 'allow-anonymous': True, - 'password-file': os.path.join(os.path.dirname(os.path.realpath(__file__)), "passwd"), - 'plugins': [ - 'VacBotAuth' - ] - }, - 'topic-check': { - 'enabled': False - } -} - -class BaseAuthPlugin: - def __init__(self, context): - self.context = context - try: - self.auth_config = self.context.config['auth'] - except KeyError: - self.context.logger.warning("'auth' section not found in context configuration") - - def authenticate(self, *args, **kwargs): - if not self.auth_config: - # auth config section not found - self.context.logger.warning("'auth' section not found in context configuration") - return False - return True - -class VacBotAuth(BaseAuthPlugin): - def __init__(self, context): - super().__init__(context) +class MQTTServer(): + clients = [] + exit_flag = False + default_config = {} @asyncio.coroutine - def authenticate(self, *args, **kwargs): - authenticated = super().authenticate(*args, **kwargs) - if authenticated: - allow_anonymous = self.auth_config.get('allow-anonymous', True) # allow anonymous by default - if allow_anonymous: - authenticated = True - self.context.logger.debug("Authentication success: config allows anonymous") - else: - try: - session = kwargs.get('session', None) - authenticated = True if session.username else False - if self.context.logger.isEnabledFor(logging.DEBUG): - if authenticated: - self.context.logger.debug("Authentication success: session has a non empty username") - else: - self.context.logger.debug("Authentication failure: session has an empty username") - except KeyError: - self.context.logger.warning("Session informations not available") - authenticated = False - return authenticated - -@asyncio.coroutine -def broker_coro(): - broker = hbmqtt.broker.Broker(config=default_config, plugin_namespace=".") + def broker_coro(self): + broker = hbmqtt.broker.Broker(config=self.default_config, plugin_namespace=".") - print(broker.plugins_manager) - yield from broker.start() + yield from broker.start() + def __init__(self, address, run_async=False): + try: + # Initialize bot server + self.default_config = { + 'listeners': { + 'default': { + 'type': 'tcp', + }, + 'tls1': { + 'bind': "{}:{}".format(address[0], address[1]), + 'ssl': 'on', + 'certfile': './certs/cert.pem', + 'keyfile': './certs/key.pem', + }, + }, + 'sys_interval': 10, + 'auth': { + 'allow-anonymous': True, + 'password-file': os.path.join(os.path.dirname(os.path.realpath(__file__)), "passwd"), + 'plugins': [ + 'VacBotAuth' + ] + }, + 'topic-check': { + 'enabled': False + } + } + if run_async: + self.run() + finally: + print("Done") + + def run(self): + formatter = "[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s" + logging.basicConfig(level=logging.DEBUG, format=formatter) + eloop = asyncio.get_event_loop().run_until_complete(self.broker_coro()) + asyncio.get_event_loop().run_forever() -if __name__ == '__main__': - formatter = "[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s" - logging.basicConfig(level=logging.DEBUG, format=formatter) - eloop = asyncio.get_event_loop().run_until_complete(broker_coro()) - - asyncio.get_event_loop().run_forever() \ No newline at end of file From b184e1c2c673e3ce478ff1cc55032672ea7dc616 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Wed, 30 Jan 2019 10:20:54 -0500 Subject: [PATCH 03/34] Add bumper plugin to mqtt --- bumper/confserver.py | 2 +- bumper/mqttserver.py | 41 +++++++++++++++++++++++++++++++++++------ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/bumper/confserver.py b/bumper/confserver.py index bf72faf..7651b47 100644 --- a/bumper/confserver.py +++ b/bumper/confserver.py @@ -45,7 +45,7 @@ class RequestHandler(BaseHTTPRequestHandler): body = { "devices": [ { - "class": "tmpClass", + "class": "ls1ok3", "company": "eco-ng", "did": "tmpDeviceID", "name": "tmpName", diff --git a/bumper/mqttserver.py b/bumper/mqttserver.py index 441c19e..1bf0374 100644 --- a/bumper/mqttserver.py +++ b/bumper/mqttserver.py @@ -5,19 +5,43 @@ import asyncio import os from hbmqtt.broker import Broker import hbmqtt +import pkg_resources + + +class BumperMQTTPlugin: + def __init__(self, context): + self.context = context + logging.debug('Bumper Plugin Initialized') + + @asyncio.coroutine + def on_broker_client_connected(self, client_id): + logging.debug('Bumper Connection: %s connected' % client_id) + #yield from self.context.broadcast_message('location/%s' % client_id, b'home') + + @asyncio.coroutine + def on_broker_client_disconnected(self, client_id): + logging.debug('Bumper Connection: %s disconnected' % client_id) + #yield from self.context.broadcast_message('location/%s' % client_id, b'not_home') + class MQTTServer(): clients = [] exit_flag = False - default_config = {} + default_config = {} @asyncio.coroutine - def broker_coro(self): - broker = hbmqtt.broker.Broker(config=self.default_config, plugin_namespace=".") - - yield from broker.start() + def broker_coro(self): + broker = hbmqtt.broker.Broker(config=self.default_config) + yield from broker.start() def __init__(self, address, run_async=False): + #The below adds a plugin to the hbmqtt.broker.plugins without having to futz with setup.py + distribution = pkg_resources.Distribution("hbmqtt.broker.plugins") + bumper_plugin = pkg_resources.EntryPoint.parse('bumper = bumper.mqttserver:BumperMQTTPlugin', dist=distribution) + distribution._ep_map = {"hbmqtt.broker.plugins": {"bumper": bumper_plugin}} + pkg_resources.working_set.add(distribution) + #for entry_point in pkg_resources.iter_entry_points("hbmqtt.broker.plugins"): + # print(entry_point) try: # Initialize bot server self.default_config = { @@ -37,7 +61,12 @@ class MQTTServer(): 'allow-anonymous': True, 'password-file': os.path.join(os.path.dirname(os.path.realpath(__file__)), "passwd"), 'plugins': [ - 'VacBotAuth' + '' #No plugins == no auth + ] + }, + 'broker': { + 'plugins': [ + 'bumper' ] }, 'topic-check': { From 58e7ce0653ec55d3434c825b87c0eb30041fc621 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Thu, 31 Jan 2019 09:43:06 -0500 Subject: [PATCH 04/34] WIP: Working POC Basics are in and this can act as a bumper server for control of D901. TODO: Better threading and cleanup --- Pipfile | 1 + bumper.py | 18 +++- bumper/__init__.py | 18 ++++ bumper/confserver.py | 52 ++++++----- bumper/mqttserver.py | 205 +++++++++++++++++++++++++++++++++++++------ 5 files changed, 244 insertions(+), 50 deletions(-) diff --git a/Pipfile b/Pipfile index cb8599b..fff0c8b 100644 --- a/Pipfile +++ b/Pipfile @@ -5,5 +5,6 @@ name = "pypi" [packages] hbmqtt = "*" +paho-mqtt = "*" [dev-packages] diff --git a/bumper.py b/bumper.py index 3a7bb61..efbcd8c 100644 --- a/bumper.py +++ b/bumper.py @@ -3,6 +3,7 @@ import logging import bumper import sys, socket +import time logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(levelname)-8s %(message)s') @@ -14,11 +15,20 @@ xmpp_address = ("0.0.0.0", 5223) #mqtt_address = (socket.gethostbyname(socket.gethostname()), 8883) mqtt_address = ("0.0.0.0", 8883) -# start conf server (async) -conf_server = bumper.ConfServer(conf_address, usessl=True, run_async=True) -# start mqtt server (async) -mqtt_server = bumper.MQTTServer(mqtt_address, run_async=True) +# A default bot could be set here to automatically add it as available +# dbot = bumper.VacBotDevice("did", "class", "resource", "name","nick" ) +# bclient = bumper.bumper_clients_var +# bclienttemp = bclient.get() +# bclienttemp.append(dbot.asdict()) +# bclient.set(bclienttemp) +# start mqtt server (async) +mqtt_server = bumper.MQTTServer(mqtt_address, run_async=True,bumper_clients=bumper.bumper_clients_var) +time.sleep(1.5) #Wait for broker startup +# start mqtt server (async) +mqtt_helperbot = bumper.MQTTHelperBot(mqtt_address, run_async=True,bumper_clients=bumper.bumper_clients_var) +# start conf server (async) +conf_server = bumper.ConfServer(conf_address, usessl=True, run_async=True, bumper_clients=bumper.bumper_clients_var, helperbot=mqtt_helperbot) # start xmpp server (sync) xmpp_server = bumper.XMPPServer(xmpp_address) diff --git a/bumper/__init__.py b/bumper/__init__.py index e75e9aa..574ad3a 100644 --- a/bumper/__init__.py +++ b/bumper/__init__.py @@ -2,4 +2,22 @@ from .confserver import ConfServer from .mqttserver import MQTTServer +from .mqttserver import MQTTHelperBot from .xmppserver import XMPPServer +import asyncio +import contextvars + +bumper_clients_var = contextvars.ContextVar('bumper_clients', default=[]) + +class VacBotDevice(object): + def __init__(self,did="", vac_bot_device_class="",resource="" , name="", nick="", company="eco-ng"): + self.vac_bot_device_class = vac_bot_device_class + self.company = company + self.did = did + self.name = name + self.nick = nick + self.resource = resource + + def asdict(self): + return {"class": self.vac_bot_device_class, "company": self.company, + "did": self.did, "name": self.name, "nick": self.nick, "resource": self.resource} diff --git a/bumper/confserver.py b/bumper/confserver.py index 7651b47..d3edc3d 100644 --- a/bumper/confserver.py +++ b/bumper/confserver.py @@ -6,12 +6,19 @@ from http.server import BaseHTTPRequestHandler from http import HTTPStatus from threading import Thread import socket, logging, ssl, json, sys +import asyncio +import contextvars +import bumper +import string +import random class RequestHandler(BaseHTTPRequestHandler): -#class RequestHandler(SimpleHTTPRequestHandler): + bumper_clients = contextvars.ContextVar + helperbot = object + def do_POST(self): - try: + try: self.protocol_version = 'HTTP/1.1' content_length = int(self.headers['Content-Length']) post_data = self.rfile.read(content_length) @@ -23,6 +30,10 @@ class RequestHandler(BaseHTTPRequestHandler): body = {"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]} elif "notify_engine.do" in str(self.path): body = {"ret":"ok"} + elif "iot/devmanager.do" in str(self.path): #Handle rest commands + randomid = ''.join(random.sample(string.ascii_letters,6)) + retcmd = self.helperbot.send_command(json_body, randomid) + body = retcmd else: todo = json_body['todo'] if todo == 'FindBest': @@ -40,19 +51,9 @@ class RequestHandler(BaseHTTPRequestHandler): "token": json_body["token"] } elif todo == 'GetDeviceList': - #Find a way to handle this automatically - #Maybe keep a list of devices from those that have checked into MQTT/XMPP/etc and return them + active_bots = self.bumper_clients.get() body = { - "devices": [ - { - "class": "ls1ok3", - "company": "eco-ng", - "did": "tmpDeviceID", - "name": "tmpName", - "nick": "tmpNick", - "resource": "tmpResource" - } - ], + "devices": active_bots, "result": "ok", "todo": "result" } @@ -73,8 +74,7 @@ class RequestHandler(BaseHTTPRequestHandler): self.protocol_version = 'HTTP/1.1' logging.debug("Path: " + self.path) - if "/login?" in str(self.path): - + if "/login?" in str(self.path): #Could implement basic auth if you wanted, or just accept anything #Next up parse the path for variables body = { @@ -136,11 +136,18 @@ class RequestHandler(BaseHTTPRequestHandler): class HTTPServerThread(HTTPServer, Thread): - def __init__(self, server_address): + bumper_clients = contextvars.ContextVar + def __init__(self, server_address, bumper_clients, helperbot): Thread.__init__(self) self.server_address = server_address + self.bumper_clients = bumper_clients + self.helperbot = helperbot self.handler = RequestHandler + self.handler.bumper_clients = self.bumper_clients + self.handler.helperbot = self.helperbot + self.exit_flag = False + HTTPServer.__init__(self, self.server_address, self.handler) def handle_error(self, request, client_address): @@ -161,14 +168,18 @@ class HTTPServerThread(HTTPServer, Thread): class ConfServer(): - def __init__(self, address, usessl=False, run_async=True): - self.run_async = run_async - self.server = HTTPServerThread(address) + bumper_clients = contextvars.ContextVar + + def __init__(self, address, usessl=False, run_async=True, bumper_clients=contextvars.ContextVar, helperbot=None): + self.run_async = run_async + self.server = HTTPServerThread(address, bumper_clients, helperbot) + try: if usessl: self.server.socket = ssl.wrap_socket(self.server.socket, keyfile='./certs/key.pem', certfile='./certs/cert.pem', server_side=True) if self.run_async: self.server.start() + else: try: self.server.run() @@ -176,6 +187,7 @@ class ConfServer(): self.disconnect() except Exception as e: logging.error('ConfServer: {}'.format(e)) + def disconnect(self): logging.info('ConfServer: shutting down...') self.server.disconnect() diff --git a/bumper/mqttserver.py b/bumper/mqttserver.py index 1bf0374..5c52c57 100644 --- a/bumper/mqttserver.py +++ b/bumper/mqttserver.py @@ -4,37 +4,178 @@ import logging import asyncio import os from hbmqtt.broker import Broker +from hbmqtt.client import MQTTClient import hbmqtt import pkg_resources +import contextvars +import time +import bumper +from threading import Thread +import ssl +from paho.mqtt.client import Client as ClientMQTT +from paho.mqtt import publish as MQTTPublish +from paho.mqtt import subscribe as MQTTSubscribe -class BumperMQTTPlugin: +class BumperMQTTPlugin: def __init__(self, context): self.context = context + try: + self.bots = self.context.config['bots'] + except KeyError: + self.context.logger.warning("'bots' section not found in context configuration") logging.debug('Bumper Plugin Initialized') - @asyncio.coroutine - def on_broker_client_connected(self, client_id): + async def on_broker_client_connected(self, client_id): logging.debug('Bumper Connection: %s connected' % client_id) - #yield from self.context.broadcast_message('location/%s' % client_id, b'home') + connected_bots = self.bots['connected_bots'].get() + didsplit = str(client_id).split("@") + #If this isn't a fake user (fuid) then add as a bot + if not (str(didsplit[0]).startswith("fuid") or str(didsplit[0]).startswith("helper")): + tmpbotdetail = str(didsplit[1]).split("/") + newbot = bumper.VacBotDevice() + newbot.did = didsplit[0] + newbot.vac_bot_device_class = tmpbotdetail[0] + newbot.resource = tmpbotdetail[1] + botactive = False + for bot in connected_bots: + if bot['did'] == newbot.did: + botactive = True + + if botactive == False: + connected_bots.append(newbot.asdict()) + + self.bots['connected_bots'].set(connected_bots) - @asyncio.coroutine - def on_broker_client_disconnected(self, client_id): + logging.debug('Connected Bots: %s' %self.bots['connected_bots'].get()) + + async def on_broker_client_disconnected(self, client_id): logging.debug('Bumper Connection: %s disconnected' % client_id) - #yield from self.context.broadcast_message('location/%s' % client_id, b'not_home') + #To do handle removing bot + +class MQTTHelperBot(ClientMQTT): -class MQTTServer(): - clients = [] + def __init__(self, address, run_async=False, bumper_clients=contextvars.ContextVar): + ClientMQTT.__init__(self) + self.address = address + self._client_id = "helper1@bumper/helper1" + self.command_responses = contextvars.ContextVar('command_responses', default=[]) + + try: + if run_async: + hloop = asyncio.new_event_loop() + logging.debug("Starting MQTT HelperBot Thread: 1") + mserver = Thread(name="MQTTHelperBot_Thread",target=self.run_helperbot, args=(hloop,)) + mserver.setDaemon(True) + mserver.start() + + else: + self.run_helperbot() + + except: + logging.exception("Exception") + pass + + def run_helperbot(self, loop): + formatter = "[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s" + logging.basicConfig(level=logging.DEBUG, format=formatter) + asyncio.set_event_loop(loop) + loop.run_until_complete(self.start_helper_bot()) + loop.run_forever() + + async def start_helper_bot(self): + #self._on_log = self.on_log #This provides more logging than needed, even for debug + self._on_message = self.get_msg + self._on_connect = self.on_connect + + #TODO: This is pretty insecure and accepts any cert, maybe actually check? + ssl_ctx = ssl.create_default_context() + ssl_ctx.check_hostname = False + ssl_ctx.verify_mode = ssl.CERT_NONE + self.tls_set_context(ssl_ctx) + self.tls_insecure_set(True) + + self.connect(self.address[0], self.address[1]) + self.loop_start() + + + def on_connect(self, client, userdata, flags, rc): + if rc != 0: + logging.error("HelperBot - error connecting with MQTT Return {}".format(rc)) + raise RuntimeError("HelperBot - error connecting with MQTT Return {}".format(rc)) + + else: + logging.debug("HelperBot - Connected with result code "+str(rc)) + logging.debug("HelperBot - Subscribing to all") + + + self.subscribe('iot/p2p/+/+/+/+/helper1/bumper/helper1/+/+/+', qos=0) + self.subscribe('iot/p2p/+', qos=0) + + + def get_msg(self, client, userdata, message): + logging.debug("HelperBot MQTT Received Message on Topic: {} - Message: {}".format(message.topic, str(message.payload.decode("utf-8")))) + print(str(message.payload.decode("utf-8"))) + cresp = self.command_responses.get() + cresp.append({"topic": message.topic,"payload":str(message.payload.decode("utf-8"))}) + self.command_responses.set(cresp) + + async def wait_for_resp(self, requestid): + t_end = time.time() + 10 + while time.time() < t_end: + await asyncio.sleep(0.2) + responses = self.command_responses.get() + if len(responses) > 0: + for msg in responses: + topic = str(msg['topic']).split("/") + if (topic[6] == "helper1" and topic[10] == requestid): + logging.debug('Vac Responses MQTT: Topic: %s Payload: %s' % (msg['topic'], msg['payload'])) + resp = { + "id": requestid, + "ret": "ok", + "resp": msg['payload'] + } + cresp = self.command_responses.get() + cresp.remove(msg) + self.command_responses.set(cresp) + return resp + + return { "id": requestid, "errno": "timeout", "ret": "fail" } + + def send_command(self, cmdjson, requestid): + ttopic = "iot/p2p/{}/helper1/bumper/helper1/{}/{}/{}/q/{}/{}".format(cmdjson["cmdName"], + cmdjson["toId"], cmdjson["toType"], cmdjson["toRes"], requestid, cmdjson["payloadType"]) + self.publish(ttopic, cmdjson["payload"]) + + loop = asyncio.new_event_loop() + resp = loop.run_until_complete(self.wait_for_resp(requestid)) + + print(resp) + return resp + + +class MQTTServer(): exit_flag = False default_config = {} + bumper_clients = [] - @asyncio.coroutine - def broker_coro(self): - broker = hbmqtt.broker.Broker(config=self.default_config) - yield from broker.start() + async def broker_coro(self): + broker = hbmqtt.broker.Broker(config=self.default_config) + for plugin in broker.plugins_manager.plugins: + if plugin.name == 'broker_sys': + broker.plugins_manager.plugins.remove(plugin) + if plugin.name == 'packet_logger_plugin': + broker.plugins_manager.plugins.remove(plugin) + + await broker.start() - def __init__(self, address, run_async=False): + async def active_bot_listing(self): + while True: + await asyncio.sleep(5) + logging.debug('Connected bots: %s' % self.bumper_clients.get()) + + def __init__(self, address, run_async=False, bumper_clients=contextvars.ContextVar): #The below adds a plugin to the hbmqtt.broker.plugins without having to futz with setup.py distribution = pkg_resources.Distribution("hbmqtt.broker.plugins") bumper_plugin = pkg_resources.EntryPoint.parse('bumper = bumper.mqttserver:BumperMQTTPlugin', dist=distribution) @@ -42,6 +183,8 @@ class MQTTServer(): pkg_resources.working_set.add(distribution) #for entry_point in pkg_resources.iter_entry_points("hbmqtt.broker.plugins"): # print(entry_point) + + self.bumper_clients = bumper_clients try: # Initialize bot server self.default_config = { @@ -64,23 +207,33 @@ class MQTTServer(): '' #No plugins == no auth ] }, - 'broker': { - 'plugins': [ - 'bumper' - ] - }, 'topic-check': { 'enabled': False + }, + 'bots':{ + 'connected_bots': bumper_clients } } if run_async: - self.run() - finally: - print("Done") + sloop = asyncio.new_event_loop() + logging.debug("Starting MQTTServer Thread: 1") + mserver = Thread(name="MQTTServer_Thread",target=self.run_server, args=(sloop,)) + mserver.setDaemon(True) + mserver.start() + + else: + self.run_server() - def run(self): + except: + logging.exception("Exception") + pass + + + def run_server(self, loop): formatter = "[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s" - logging.basicConfig(level=logging.DEBUG, format=formatter) - eloop = asyncio.get_event_loop().run_until_complete(self.broker_coro()) - asyncio.get_event_loop().run_forever() + logging.basicConfig(level=logging.DEBUG, format=formatter) + asyncio.set_event_loop(loop) + loop.run_until_complete(self.broker_coro()) + #loop.run_until_complete(self.active_bot_listing()) + loop.run_forever() From 98068046e9be867ab2d38ba80b7e15cc106e78d5 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Fri, 1 Feb 2019 01:47:14 -0500 Subject: [PATCH 05/34] Working with app and Add CA Tested with the EcoVacs app, almost everything works. CA Added for managing certs. A self-signed may work as well. TBD --- bumper.py | 2 +- bumper/__init__.py | 6 + bumper/confserver.py | 200 ++++++++++++++++++-------- bumper/mqttserver.py | 21 ++- bumper/xmppserver.py | 6 +- certs/CA/cacert.pem | 85 +++++++++++ certs/CA/careq.pem | 18 +++ certs/CA/certreqs/bumper_req.txt | 19 +++ certs/CA/certsdb/99BD46167599BDB4.pem | 85 +++++++++++ certs/CA/certsdb/99BD46167599BDB5.pem | 85 +++++++++++ certs/CA/index.txt | 2 + certs/CA/index.txt.attr | 1 + certs/CA/index.txt.attr.old | 1 + certs/CA/index.txt.old | 1 + certs/CA/openssl.cnf | 180 +++++++++++++++++++++++ certs/CA/private/cakey.pem | 30 ++++ certs/CA/private/key.pem | 28 ++++ certs/CA/serial | 1 + certs/CA/serial.old | 1 + certs/cert.pem | 114 +++++++++++---- certs/cert.pem.bak | 33 +++++ certs/key.pem | 79 ++++------ certs/key.pem.bak | 51 +++++++ 23 files changed, 895 insertions(+), 154 deletions(-) create mode 100644 certs/CA/cacert.pem create mode 100644 certs/CA/careq.pem create mode 100644 certs/CA/certreqs/bumper_req.txt create mode 100644 certs/CA/certsdb/99BD46167599BDB4.pem create mode 100644 certs/CA/certsdb/99BD46167599BDB5.pem create mode 100644 certs/CA/index.txt create mode 100644 certs/CA/index.txt.attr create mode 100644 certs/CA/index.txt.attr.old create mode 100644 certs/CA/index.txt.old create mode 100644 certs/CA/openssl.cnf create mode 100644 certs/CA/private/cakey.pem create mode 100644 certs/CA/private/key.pem create mode 100644 certs/CA/serial create mode 100644 certs/CA/serial.old create mode 100644 certs/cert.pem.bak create mode 100644 certs/key.pem.bak diff --git a/bumper.py b/bumper.py index efbcd8c..5f004d7 100644 --- a/bumper.py +++ b/bumper.py @@ -5,7 +5,7 @@ import bumper import sys, socket import time -logging.basicConfig(level=logging.DEBUG, +logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)-8s %(message)s') #conf_address = (socket.gethostbyname(socket.gethostname()), 443) diff --git a/bumper/__init__.py b/bumper/__init__.py index 574ad3a..3195f7f 100644 --- a/bumper/__init__.py +++ b/bumper/__init__.py @@ -6,8 +6,14 @@ from .mqttserver import MQTTHelperBot from .xmppserver import XMPPServer import asyncio import contextvars +import time bumper_clients_var = contextvars.ContextVar('bumper_clients', default=[]) +current_milli_time = lambda: int(round(time.time() * 1000)) + +def get_milli_time(timetoconvert): + return int(round(timetoconvert * 1000)) + class VacBotDevice(object): def __init__(self,did="", vac_bot_device_class="",resource="" , name="", nick="", company="eco-ng"): diff --git a/bumper/confserver.py b/bumper/confserver.py index d3edc3d..78e11c7 100644 --- a/bumper/confserver.py +++ b/bumper/confserver.py @@ -2,16 +2,21 @@ from http.server import HTTPServer from http.server import BaseHTTPRequestHandler -#from http.server import SimpleHTTPRequestHandler from http import HTTPStatus from threading import Thread import socket, logging, ssl, json, sys import asyncio import contextvars -import bumper import string import random +import bumper +import time +from datetime import datetime, timedelta +import threading +from socketserver import ThreadingMixIn +class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): + """Handle requests in a separate thread.""" class RequestHandler(BaseHTTPRequestHandler): bumper_clients = contextvars.ContextVar @@ -23,48 +28,77 @@ class RequestHandler(BaseHTTPRequestHandler): content_length = int(self.headers['Content-Length']) post_data = self.rfile.read(content_length) logging.debug("Headers: " + str(self.headers)) - request_body = post_data.decode('utf-8') - logging.debug("Request: " + request_body) - json_body = json.loads(request_body) - if "getProductIotMap" in str(self.path): - body = {"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]} - elif "notify_engine.do" in str(self.path): - body = {"ret":"ok"} - elif "iot/devmanager.do" in str(self.path): #Handle rest commands - randomid = ''.join(random.sample(string.ascii_letters,6)) - retcmd = self.helperbot.send_command(json_body, randomid) - body = retcmd - else: - todo = json_body['todo'] - if todo == 'FindBest': - service = json_body['service'] - if service == 'EcoMsgNew': - body = {"result":"ok","ip":socket.gethostbyname(socket.gethostname()),"port":5223} - elif service == 'EcoUpdate': - body = {"result":"ok","ip":"47.88.66.164","port":8005} - elif todo == 'loginByItToken': - body = { - "todo": "result", - "result": "ok", - "userId": json_body["userId"], - "resource": json_body["resource"], - "token": json_body["token"] - } - elif todo == 'GetDeviceList': - active_bots = self.bumper_clients.get() - body = { - "devices": active_bots, - "result": "ok", - "todo": "result" - } - body = json.dumps(body) - logging.debug("Response: " + body) - body = body.encode() + body = {} + if "app_logs" in str(self.path): + body = {} + + else: + request_body = post_data.decode('utf-8') + logging.debug("Request: " + request_body) + json_body = json.loads(request_body) + + if "/product/getProductIotMap" in str(self.path): + psplit = str(self.path).split("/") + logging.debug("getProductIotMap Path Split: %s" %psplit) + body = {"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]} + elif "notify_engine.do" in str(self.path): + psplit = str(self.path).split("/") + logging.debug("notify_engine Path Split: %s" %psplit) + body = {"ret":"ok"} + elif "iot/devmanager.do" in str(self.path): #Handle rest commands + psplit = str(self.path).split("/") + logging.debug("devmanager Path Split: %s" %psplit) + randomid = ''.join(random.sample(string.ascii_letters,6)) + retcmd = self.helperbot.send_command(json_body, randomid) + body = retcmd + elif "/users/user.do" in str(self.path): #Handle user commands + psplit = str(self.path).split("/") + logging.debug("userdo Path Split: %s" %psplit) + todo = json_body['todo'] + if todo == 'FindBest': + service = json_body['service'] + if service == 'EcoMsgNew': + body = {"result":"ok","ip":socket.gethostbyname(socket.gethostname()),"port":5223} + elif service == 'EcoUpdate': + body = {"result":"ok","ip":"47.88.66.164","port":8005} + elif todo == 'loginByItToken': + psplit = str(self.path).split("/") + logging.debug("LoginByItToken Split: %s" %psplit) + body = { + "resource": json_body["resource"], + "result": "ok", + "todo": "result", + "token": json_body["token"], #RandomChar(32) + "userId": json_body["userId"] #RandomChar(16) + } + #TODO: Randomize + #self.send_header("ETag", "ETag1234") + elif todo == 'GetDeviceList': + active_bots = self.bumper_clients.get() + body = { + "devices": active_bots, + "result": "ok", + "todo": "result" + } + + elif "ESTInt" in str(self.path): #Handle user commands: + psplit = str(self.path).split("/") + logging.debug("bigdata Path Split: %s" %psplit) + {"header":{"result_code":"000000"},"body":{}} + else: + psplit = str(self.path).split("/") + logging.debug("Other Post Path Split: %s" %psplit) + body = {} + + body = json.dumps(body) + logging.debug("Response: " + str(body)) + body = body.encode(encoding='UTF-8') self.send_response(HTTPStatus.OK) self.send_header('Content-Type', 'application/json; charset=utf-8') self.send_header('Connection', 'keep-alive') self.send_header('Content-Length', len(body)) self.end_headers() + self.wfile.write(body) except Exception as e: logging.error('ConfServer POST: {}'.format(e)) @@ -74,35 +108,39 @@ class RequestHandler(BaseHTTPRequestHandler): self.protocol_version = 'HTTP/1.1' logging.debug("Path: " + self.path) - if "/login?" in str(self.path): - #Could implement basic auth if you wanted, or just accept anything - #Next up parse the path for variables + if "/user/login?" in str(self.path): + psplit = str(self.path).split("/") + logging.debug("Login Path Split: %s" %psplit) + #Could implement basic auth if you wanted, or just accept anything body = { "code": "0000", "data": { - "accessToken": "temp_accesstoken", - "country": "us", + "accessToken": "tempaccesstoken", #Random chars 32 length + "country": psplit[3], "email": "null@null.com", - "uid": "fuid_1", - "username": "fusername_1" + "uid": "fuid_1", #Date(14)_RandomChars(32) + "username": "fusername_1" #Random chars 8 }, "msg": "操作成功", - "time": 1547211900690 - } - + "time": bumper.current_milli_time() + } - elif "/getAuthCode?" in str(self.path): + elif "/user/getAuthCode?" in str(self.path): + psplit = str(self.path).split("/") + logging.debug("getAuthCode Path Split: %s" %psplit) body = { "code": "0000", "data": { - "authCode": "temp_authcode", - "ecovacsUid": "fuid_1" + "authCode": "{}_tempauthcode".format(psplit[3]), #countrycode_randomchars(32) + "ecovacsUid": "fuid_1" #Date(14)_RandomChars(32) }, "msg": "操作成功", - "time": 1547211900808 - } + "time": bumper.current_milli_time() + } - elif "/checkVersion?" in str(self.path): + elif "/common/checkVersion?" in str(self.path): + psplit = str(self.path).split("/") + logging.debug("checkVersion Path Split: %s" %psplit) body = { "code": "0000", "data": { @@ -115,16 +153,52 @@ class RequestHandler(BaseHTTPRequestHandler): "v": None }, "msg": "操作成功", - "time": 1548829850462 + "time": bumper.current_milli_time() } - elif "/logout?" in str(self.path): - body = {"code": "0000","data": None,"msg": "操作成功","time": 1548830041460} + + + elif "/user/checkAgreement?" in str(self.path): + psplit = str(self.path).split("/") + logging.debug("checkAgreement Path Split: %s" %psplit) + body = { + "code": "0000", + "data": [], + "msg": "操作成功", + "time": bumper.current_milli_time() + + } + elif "/campaign/homePageAlert?" in str(self.path): + psplit = str(self.path).split("/") + logging.debug("homePageAlert Path Split: %s" %psplit) + nextAlert = bumper.get_milli_time((datetime.now() + timedelta(hours=12)).timestamp()) + logging.debug("Next Alert %s" % nextAlert) + body = { + "code": "0000", + "data": { + "clickSchemeUrl": None, + "clickWebUrl": None, + "hasCampaign": "N", + "imageUrl": None, + "nextAlertTime": nextAlert, + "serverTime": bumper.current_milli_time() + }, + "msg": "操作成功", + "time": bumper.current_milli_time() + } + + + elif "/user/logout?" in str(self.path): + psplit = str(self.path).split("/") + logging.debug("Logout Path Split: %s" %psplit) + body = {"code": "0000","data": None,"msg": "操作成功", "time": bumper.current_milli_time()} else: + psplit = str(self.path).split("/") + logging.debug("Other Get Path Split: %s" %psplit) body = {} - body = json.dumps(body) - logging.debug("Response: " + body) - body = body.encode() + body = json.dumps(body) + logging.debug("Response: " + str(body)) + body = body.encode(encoding='UTF-8') self.send_response(HTTPStatus.OK) self.send_header('Content-Type', 'application/json; charset=utf-8') self.send_header('Connection', 'keep-alive') @@ -135,7 +209,7 @@ class RequestHandler(BaseHTTPRequestHandler): logging.error('ConfServer GET: {}'.format(e)) -class HTTPServerThread(HTTPServer, Thread): +class HTTPServerThread(ThreadedHTTPServer, Thread): bumper_clients = contextvars.ContextVar def __init__(self, server_address, bumper_clients, helperbot): Thread.__init__(self) @@ -148,7 +222,7 @@ class HTTPServerThread(HTTPServer, Thread): self.exit_flag = False - HTTPServer.__init__(self, self.server_address, self.handler) + ThreadedHTTPServer.__init__(self, self.server_address, self.handler) def handle_error(self, request, client_address): self.close_request(request) diff --git a/bumper/mqttserver.py b/bumper/mqttserver.py index 5c52c57..3a1f6d1 100644 --- a/bumper/mqttserver.py +++ b/bumper/mqttserver.py @@ -9,12 +9,13 @@ import hbmqtt import pkg_resources import contextvars import time -import bumper from threading import Thread import ssl from paho.mqtt.client import Client as ClientMQTT from paho.mqtt import publish as MQTTPublish from paho.mqtt import subscribe as MQTTSubscribe +import bumper +import json class BumperMQTTPlugin: @@ -79,7 +80,7 @@ class MQTTHelperBot(ClientMQTT): def run_helperbot(self, loop): formatter = "[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s" - logging.basicConfig(level=logging.DEBUG, format=formatter) + logging.basicConfig(level=logging.INFO, format=formatter) asyncio.set_event_loop(loop) loop.run_until_complete(self.start_helper_bot()) loop.run_forever() @@ -116,8 +117,9 @@ class MQTTHelperBot(ClientMQTT): def get_msg(self, client, userdata, message): logging.debug("HelperBot MQTT Received Message on Topic: {} - Message: {}".format(message.topic, str(message.payload.decode("utf-8")))) - print(str(message.payload.decode("utf-8"))) + logging.debug(str(message.payload.decode("utf-8"))) cresp = self.command_responses.get() + #str(message.payload.decode("utf-8") cresp.append({"topic": message.topic,"payload":str(message.payload.decode("utf-8"))}) self.command_responses.set(cresp) @@ -131,10 +133,15 @@ class MQTTHelperBot(ClientMQTT): topic = str(msg['topic']).split("/") if (topic[6] == "helper1" and topic[10] == requestid): logging.debug('Vac Responses MQTT: Topic: %s Payload: %s' % (msg['topic'], msg['payload'])) + if topic[11] == "j": + resppayload = json.loads(msg['payload']) + else: + resppayload = str(msg['payload']) + logging.debug("Resp Payload: %s" % resppayload) resp = { "id": requestid, "ret": "ok", - "resp": msg['payload'] + "resp": resppayload } cresp = self.command_responses.get() cresp.remove(msg) @@ -146,12 +153,12 @@ class MQTTHelperBot(ClientMQTT): def send_command(self, cmdjson, requestid): ttopic = "iot/p2p/{}/helper1/bumper/helper1/{}/{}/{}/q/{}/{}".format(cmdjson["cmdName"], cmdjson["toId"], cmdjson["toType"], cmdjson["toRes"], requestid, cmdjson["payloadType"]) - self.publish(ttopic, cmdjson["payload"]) + self.publish(ttopic, str(cmdjson["payload"])) loop = asyncio.new_event_loop() resp = loop.run_until_complete(self.wait_for_resp(requestid)) - print(resp) + logging.debug(resp) return resp @@ -231,7 +238,7 @@ class MQTTServer(): def run_server(self, loop): formatter = "[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s" - logging.basicConfig(level=logging.DEBUG, format=formatter) + logging.basicConfig(level=logging.INFO, format=formatter) asyncio.set_event_loop(loop) loop.run_until_complete(self.broker_coro()) #loop.run_until_complete(self.active_bot_listing()) diff --git a/bumper/xmppserver.py b/bumper/xmppserver.py index c6f5a6e..6be00d8 100644 --- a/bumper/xmppserver.py +++ b/bumper/xmppserver.py @@ -125,7 +125,8 @@ class Client(threading.Thread): logging.info('client connected: {}'.format(self.address)) self._set_state('CONNECT') while True: - data = self.connection.recv(4096) + if not self.connection._closed: + data = self.connection.recv(4096) if data: logging.debug('from {}: {}'.format(self.address, data.decode('utf-8'))) try: @@ -174,6 +175,9 @@ class Client(threading.Thread): except Exception as e: logging.error('XMPPServer: {}'.format(e)) self._set_state('DISCONNECT') + except OSError as e: + logging.error('XMPPServer: {}'.format(e)) + self._set_state('DISCONNECT') except Exception as e: logging.error('XMPPServer: {}'.format(e)) self._set_state('DISCONNECT') diff --git a/certs/CA/cacert.pem b/certs/CA/cacert.pem new file mode 100644 index 0000000..a219c22 --- /dev/null +++ b/certs/CA/cacert.pem @@ -0,0 +1,85 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 11078087720677522868 (0x99bd46167599bdb4) + Signature Algorithm: sha1WithRSAEncryption + Issuer: C=US, ST=California, O=Bumper, OU=Bumper, CN=Bumper + Validity + Not Before: Feb 1 05:59:32 2019 GMT + Not After : Jan 29 05:59:32 2029 GMT + Subject: C=US, ST=California, O=Bumper, OU=Bumper, CN=Bumper + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:aa:6e:af:15:99:3b:09:32:a1:a7:4b:ee:f7:13: + 68:c1:94:4f:09:71:54:99:02:2d:27:c1:5f:16:2e: + 45:d3:a6:44:24:fb:bd:f0:9b:a9:f6:6c:49:6f:09: + 13:76:d6:5b:88:c2:fc:8b:8b:14:4c:39:df:8a:b5: + 51:a8:83:c1:73:ca:aa:f1:b7:b4:38:e1:f0:91:35: + be:6f:c6:99:7c:e1:ef:d9:c0:ee:d5:fb:f8:f5:0f: + a7:96:b3:78:95:d6:2a:04:7d:3b:31:42:b6:33:c0: + 36:2c:06:f1:6d:db:11:99:16:3e:77:c6:c6:df:ed: + 95:40:b8:39:a2:82:40:fe:bb:f6:8b:63:0c:c6:96: + ae:fd:3b:c7:79:a4:f5:5f:4e:83:87:27:33:bb:6f: + cf:43:86:55:73:6c:0c:28:61:89:66:72:b3:90:3d: + d8:54:53:6a:3a:57:76:01:05:58:ac:78:3d:0d:0a: + 52:b4:b7:be:2b:4e:9a:6a:f4:f8:a2:3f:21:99:b6: + b4:74:fb:d5:c0:23:7b:e8:51:f5:0a:ec:6a:11:4e: + 9b:c3:f2:56:00:06:3b:58:e3:cf:67:b6:c3:26:50: + 0e:f7:72:11:3f:39:2c:c9:b7:d5:69:1a:33:dc:8f: + 81:66:c5:88:3f:c7:01:94:f0:3d:ac:aa:f3:a0:2a: + e8:c7 + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Subject Key Identifier: + 5E:39:C7:78:31:83:30:51:82:D9:88:9B:73:0B:72:EF:44:74:7B:D5 + X509v3 Authority Key Identifier: + keyid:5E:39:C7:78:31:83:30:51:82:D9:88:9B:73:0B:72:EF:44:74:7B:D5 + DirName:/C=US/ST=California/O=Bumper/OU=Bumper/CN=Bumper + serial:99:BD:46:16:75:99:BD:B4 + + X509v3 Basic Constraints: + CA:TRUE + X509v3 Subject Alternative Name: + email:null@null.com + Signature Algorithm: sha1WithRSAEncryption + 5e:45:c7:34:7f:9b:a4:6b:cc:3c:e7:34:84:76:ee:ec:98:be: + 0f:de:ab:b2:a5:6d:59:18:eb:20:de:a4:fc:2c:4d:e4:9a:9c: + 63:99:be:16:6a:1d:c0:57:73:e8:7f:9e:78:4d:2a:e6:11:a3: + b6:57:18:84:6e:ae:a7:97:08:7e:1f:4d:33:fc:46:06:a7:d1: + 6e:3a:d1:d9:ef:ba:fd:87:b8:e0:ea:51:85:d2:a6:de:49:e1: + a6:43:d1:26:15:e2:f9:8b:8c:f0:94:fe:a3:62:dd:cc:ab:be: + a5:dc:90:d8:28:0c:85:4b:fb:df:4a:f1:ac:35:86:7f:63:fd: + ca:c6:aa:29:0b:b0:e1:e3:08:e6:4c:71:d2:08:32:c9:09:22: + 6c:61:ec:87:49:8d:77:48:7a:f7:75:86:bb:4e:17:b0:fe:43: + 4d:2f:95:bd:d5:0f:c1:ef:ba:f7:3e:e0:b6:2f:fa:4a:c9:45: + 36:97:56:a4:6d:af:86:88:ff:98:2f:b2:e4:04:4d:c4:02:20: + fc:66:8c:d5:2b:e9:5a:6c:1f:d2:67:34:c3:39:7e:f4:c7:37: + 53:6b:11:99:a8:29:8b:3f:f6:3a:82:ae:f5:2c:d5:b7:23:88: + bb:03:84:ad:cb:d0:f6:8d:24:9f:54:08:d1:39:da:1a:a9:f6: + 1b:c9:53:65 +-----BEGIN CERTIFICATE----- +MIIEADCCAuigAwIBAgIJAJm9RhZ1mb20MA0GCSqGSIb3DQEBBQUAMFUxCzAJBgNV +BAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMQ8wDQYDVQQKEwZCdW1wZXIxDzAN +BgNVBAsTBkJ1bXBlcjEPMA0GA1UEAxMGQnVtcGVyMB4XDTE5MDIwMTA1NTkzMloX +DTI5MDEyOTA1NTkzMlowVTELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3Ju +aWExDzANBgNVBAoTBkJ1bXBlcjEPMA0GA1UECxMGQnVtcGVyMQ8wDQYDVQQDEwZC +dW1wZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCqbq8VmTsJMqGn +S+73E2jBlE8JcVSZAi0nwV8WLkXTpkQk+73wm6n2bElvCRN21luIwvyLixRMOd+K +tVGog8Fzyqrxt7Q44fCRNb5vxpl84e/ZwO7V+/j1D6eWs3iV1ioEfTsxQrYzwDYs +BvFt2xGZFj53xsbf7ZVAuDmigkD+u/aLYwzGlq79O8d5pPVfToOHJzO7b89DhlVz +bAwoYYlmcrOQPdhUU2o6V3YBBViseD0NClK0t74rTppq9PiiPyGZtrR0+9XAI3vo +UfUK7GoRTpvD8lYABjtY489ntsMmUA73chE/OSzJt9VpGjPcj4FmxYg/xwGU8D2s +qvOgKujHAgMBAAGjgdIwgc8wHQYDVR0OBBYEFF45x3gxgzBRgtmIm3MLcu9EdHvV +MIGFBgNVHSMEfjB8gBReOcd4MYMwUYLZiJtzC3LvRHR71aFZpFcwVTELMAkGA1UE +BhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExDzANBgNVBAoTBkJ1bXBlcjEPMA0G +A1UECxMGQnVtcGVyMQ8wDQYDVQQDEwZCdW1wZXKCCQCZvUYWdZm9tDAMBgNVHRME +BTADAQH/MBgGA1UdEQQRMA+BDW51bGxAbnVsbC5jb20wDQYJKoZIhvcNAQEFBQAD +ggEBAF5FxzR/m6RrzDznNIR27uyYvg/eq7KlbVkY6yDepPwsTeSanGOZvhZqHcBX +c+h/nnhNKuYRo7ZXGIRurqeXCH4fTTP8Rgan0W460dnvuv2HuODqUYXSpt5J4aZD +0SYV4vmLjPCU/qNi3cyrvqXckNgoDIVL+99K8aw1hn9j/crGqikLsOHjCOZMcdII +MskJImxh7IdJjXdIevd1hrtOF7D+Q00vlb3VD8Hvuvc+4LYv+krJRTaXVqRtr4aI +/5gvsuQETcQCIPxmjNUr6VpsH9JnNMM5fvTHN1NrEZmoKYs/9jqCrvUs1bcjiLsD +hK3L0PaNJJ9UCNE52hqp9hvJU2U= +-----END CERTIFICATE----- diff --git a/certs/CA/careq.pem b/certs/CA/careq.pem new file mode 100644 index 0000000..f4c94e5 --- /dev/null +++ b/certs/CA/careq.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIC2TCCAcECAQAwaTELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWEx +EjAQBgNVBAcTCUhhd3Rob3JuZTEPMA0GA1UEChMGQnVtcGVyMQ8wDQYDVQQLEwZC +dW1wZXIxDzANBgNVBAMTBkJ1bXBlcjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBAKpurxWZOwkyoadL7vcTaMGUTwlxVJkCLSfBXxYuRdOmRCT7vfCbqfZs +SW8JE3bWW4jC/IuLFEw534q1UaiDwXPKqvG3tDjh8JE1vm/GmXzh79nA7tX7+PUP +p5azeJXWKgR9OzFCtjPANiwG8W3bEZkWPnfGxt/tlUC4OaKCQP679otjDMaWrv07 +x3mk9V9Og4cnM7tvz0OGVXNsDChhiWZys5A92FRTajpXdgEFWKx4PQ0KUrS3vitO +mmr0+KI/IZm2tHT71cAje+hR9QrsahFOm8PyVgAGO1jjz2e2wyZQDvdyET85LMm3 +1WkaM9yPgWbFiD/HAZTwPayq86Aq6McCAwEAAaArMCkGCSqGSIb3DQEJDjEcMBow +GAYDVR0RBBEwD4ENbnVsbEBudWxsLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAQU/v +8GRj5M3Qs0at2UGxz7uKZF5RKt6hE5WreS/joaMkK4+kTqA8FsfCGz0zUyyLWNKh +1o4b99yvYXG+rP8euMyBql/WSnEcF0KYrrGyUjH0qQQfTHqC0UqolvqoQCkPbvUO +sWZVzHeVxIH3nNtXgkIS+PHvTIhDLA3CxrDSe5jz4XLaACPoK/dawojAd0Qet0zZ +GfXk9Vm/5D0k/7MPkstOTAFCvfln2cdoYhA34WNkPW+uZNGBEdXTN3Tg/Oj8qAqV +RdIXR8joIzgAJnCit1IDyTPHR88UNTlFtk4AJ9Ott4v1hmUOf3kBqB0owXj+fCpq +3Mz7yL1DioePr8h7iw== +-----END CERTIFICATE REQUEST----- diff --git a/certs/CA/certreqs/bumper_req.txt b/certs/CA/certreqs/bumper_req.txt new file mode 100644 index 0000000..f59043e --- /dev/null +++ b/certs/CA/certreqs/bumper_req.txt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIC0DCCAbgCAQAwETEPMA0GA1UEAwwGQnVtcGVyMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEA0eEqslnaJcbhqTalVMQTNT8/s6957rLSw78cZSVQEISN +MwyS4Q5tYYJzXXEmqyp8rBMWAArfWGaPO8sgKs6rC9UcYurVr1GbxtsVdRRRI/fg +tt2rtWQYdkBI7Dz9vjkcUF1tWwydWzPtKunzHGq9XNWY8NvJ4eq5dnO1oF1wyPWe +5bbKURJpA3gOXuCtEY+TOv++8PK3vdPQnkE1pFRnTfBP5rmatnReFlbfxCUCwcnq +X5a0+BeDanw4GSm4m0MCFXGWEb07P+8JUdUP+2rbMIZwUb42BBz/UKBeg410vgwh +PISyQetW4XGn3bLJk933GFO6gvOU9CVdzPdxRPPLPQIDAQABoHoweAYJKoZIhvcN +AQkOMWswaTAOBgNVHQ8BAf8EBAMCBaAwIAYDVR0lAQH/BBYwFAYIKwYBBQUHAwEG +CCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwJwYDVR0RBCAwHoINKi5lY291c2VyLm5l +dIINKi5lY292YWNzLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAloHqI4fjQEL0H0I0 +TMr+DemH5GhYyVC7hs5bPfajqUbrQdTMvfUhfcUm0JgAmicKlAvVe1+75grkXeCF +sfGBl10f5dbO9CEHBT9kzDJWhFT/ZFzGlblAwB6Kad7QGpIMKncJCZBUwkE4zrwO +wEI1hAo0zvJr/SETte4YO9JianzT1Tu5f3nVgvKYVM+XJ0KZ7ReIdrT0Ev95mZhB +tmZ1R7WeYtO8yZXVHP28uOHZcHCKgKcnrpb13yuNd6mkR643xMoirwp9t7xayJAt +RnQgvb9o8HXNZoO1whr4ZF8gNs0MQGnT72MoV5E2KSZ9HMO4nQlzj9wIiEMls+ss +UTUhBg== +-----END CERTIFICATE REQUEST----- + diff --git a/certs/CA/certsdb/99BD46167599BDB4.pem b/certs/CA/certsdb/99BD46167599BDB4.pem new file mode 100644 index 0000000..a219c22 --- /dev/null +++ b/certs/CA/certsdb/99BD46167599BDB4.pem @@ -0,0 +1,85 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 11078087720677522868 (0x99bd46167599bdb4) + Signature Algorithm: sha1WithRSAEncryption + Issuer: C=US, ST=California, O=Bumper, OU=Bumper, CN=Bumper + Validity + Not Before: Feb 1 05:59:32 2019 GMT + Not After : Jan 29 05:59:32 2029 GMT + Subject: C=US, ST=California, O=Bumper, OU=Bumper, CN=Bumper + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:aa:6e:af:15:99:3b:09:32:a1:a7:4b:ee:f7:13: + 68:c1:94:4f:09:71:54:99:02:2d:27:c1:5f:16:2e: + 45:d3:a6:44:24:fb:bd:f0:9b:a9:f6:6c:49:6f:09: + 13:76:d6:5b:88:c2:fc:8b:8b:14:4c:39:df:8a:b5: + 51:a8:83:c1:73:ca:aa:f1:b7:b4:38:e1:f0:91:35: + be:6f:c6:99:7c:e1:ef:d9:c0:ee:d5:fb:f8:f5:0f: + a7:96:b3:78:95:d6:2a:04:7d:3b:31:42:b6:33:c0: + 36:2c:06:f1:6d:db:11:99:16:3e:77:c6:c6:df:ed: + 95:40:b8:39:a2:82:40:fe:bb:f6:8b:63:0c:c6:96: + ae:fd:3b:c7:79:a4:f5:5f:4e:83:87:27:33:bb:6f: + cf:43:86:55:73:6c:0c:28:61:89:66:72:b3:90:3d: + d8:54:53:6a:3a:57:76:01:05:58:ac:78:3d:0d:0a: + 52:b4:b7:be:2b:4e:9a:6a:f4:f8:a2:3f:21:99:b6: + b4:74:fb:d5:c0:23:7b:e8:51:f5:0a:ec:6a:11:4e: + 9b:c3:f2:56:00:06:3b:58:e3:cf:67:b6:c3:26:50: + 0e:f7:72:11:3f:39:2c:c9:b7:d5:69:1a:33:dc:8f: + 81:66:c5:88:3f:c7:01:94:f0:3d:ac:aa:f3:a0:2a: + e8:c7 + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Subject Key Identifier: + 5E:39:C7:78:31:83:30:51:82:D9:88:9B:73:0B:72:EF:44:74:7B:D5 + X509v3 Authority Key Identifier: + keyid:5E:39:C7:78:31:83:30:51:82:D9:88:9B:73:0B:72:EF:44:74:7B:D5 + DirName:/C=US/ST=California/O=Bumper/OU=Bumper/CN=Bumper + serial:99:BD:46:16:75:99:BD:B4 + + X509v3 Basic Constraints: + CA:TRUE + X509v3 Subject Alternative Name: + email:null@null.com + Signature Algorithm: sha1WithRSAEncryption + 5e:45:c7:34:7f:9b:a4:6b:cc:3c:e7:34:84:76:ee:ec:98:be: + 0f:de:ab:b2:a5:6d:59:18:eb:20:de:a4:fc:2c:4d:e4:9a:9c: + 63:99:be:16:6a:1d:c0:57:73:e8:7f:9e:78:4d:2a:e6:11:a3: + b6:57:18:84:6e:ae:a7:97:08:7e:1f:4d:33:fc:46:06:a7:d1: + 6e:3a:d1:d9:ef:ba:fd:87:b8:e0:ea:51:85:d2:a6:de:49:e1: + a6:43:d1:26:15:e2:f9:8b:8c:f0:94:fe:a3:62:dd:cc:ab:be: + a5:dc:90:d8:28:0c:85:4b:fb:df:4a:f1:ac:35:86:7f:63:fd: + ca:c6:aa:29:0b:b0:e1:e3:08:e6:4c:71:d2:08:32:c9:09:22: + 6c:61:ec:87:49:8d:77:48:7a:f7:75:86:bb:4e:17:b0:fe:43: + 4d:2f:95:bd:d5:0f:c1:ef:ba:f7:3e:e0:b6:2f:fa:4a:c9:45: + 36:97:56:a4:6d:af:86:88:ff:98:2f:b2:e4:04:4d:c4:02:20: + fc:66:8c:d5:2b:e9:5a:6c:1f:d2:67:34:c3:39:7e:f4:c7:37: + 53:6b:11:99:a8:29:8b:3f:f6:3a:82:ae:f5:2c:d5:b7:23:88: + bb:03:84:ad:cb:d0:f6:8d:24:9f:54:08:d1:39:da:1a:a9:f6: + 1b:c9:53:65 +-----BEGIN CERTIFICATE----- +MIIEADCCAuigAwIBAgIJAJm9RhZ1mb20MA0GCSqGSIb3DQEBBQUAMFUxCzAJBgNV +BAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMQ8wDQYDVQQKEwZCdW1wZXIxDzAN +BgNVBAsTBkJ1bXBlcjEPMA0GA1UEAxMGQnVtcGVyMB4XDTE5MDIwMTA1NTkzMloX +DTI5MDEyOTA1NTkzMlowVTELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3Ju +aWExDzANBgNVBAoTBkJ1bXBlcjEPMA0GA1UECxMGQnVtcGVyMQ8wDQYDVQQDEwZC +dW1wZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCqbq8VmTsJMqGn +S+73E2jBlE8JcVSZAi0nwV8WLkXTpkQk+73wm6n2bElvCRN21luIwvyLixRMOd+K +tVGog8Fzyqrxt7Q44fCRNb5vxpl84e/ZwO7V+/j1D6eWs3iV1ioEfTsxQrYzwDYs +BvFt2xGZFj53xsbf7ZVAuDmigkD+u/aLYwzGlq79O8d5pPVfToOHJzO7b89DhlVz +bAwoYYlmcrOQPdhUU2o6V3YBBViseD0NClK0t74rTppq9PiiPyGZtrR0+9XAI3vo +UfUK7GoRTpvD8lYABjtY489ntsMmUA73chE/OSzJt9VpGjPcj4FmxYg/xwGU8D2s +qvOgKujHAgMBAAGjgdIwgc8wHQYDVR0OBBYEFF45x3gxgzBRgtmIm3MLcu9EdHvV +MIGFBgNVHSMEfjB8gBReOcd4MYMwUYLZiJtzC3LvRHR71aFZpFcwVTELMAkGA1UE +BhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExDzANBgNVBAoTBkJ1bXBlcjEPMA0G +A1UECxMGQnVtcGVyMQ8wDQYDVQQDEwZCdW1wZXKCCQCZvUYWdZm9tDAMBgNVHRME +BTADAQH/MBgGA1UdEQQRMA+BDW51bGxAbnVsbC5jb20wDQYJKoZIhvcNAQEFBQAD +ggEBAF5FxzR/m6RrzDznNIR27uyYvg/eq7KlbVkY6yDepPwsTeSanGOZvhZqHcBX +c+h/nnhNKuYRo7ZXGIRurqeXCH4fTTP8Rgan0W460dnvuv2HuODqUYXSpt5J4aZD +0SYV4vmLjPCU/qNi3cyrvqXckNgoDIVL+99K8aw1hn9j/crGqikLsOHjCOZMcdII +MskJImxh7IdJjXdIevd1hrtOF7D+Q00vlb3VD8Hvuvc+4LYv+krJRTaXVqRtr4aI +/5gvsuQETcQCIPxmjNUr6VpsH9JnNMM5fvTHN1NrEZmoKYs/9jqCrvUs1bcjiLsD +hK3L0PaNJJ9UCNE52hqp9hvJU2U= +-----END CERTIFICATE----- diff --git a/certs/CA/certsdb/99BD46167599BDB5.pem b/certs/CA/certsdb/99BD46167599BDB5.pem new file mode 100644 index 0000000..64ccbbf --- /dev/null +++ b/certs/CA/certsdb/99BD46167599BDB5.pem @@ -0,0 +1,85 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 11078087720677522869 (0x99bd46167599bdb5) + Signature Algorithm: sha1WithRSAEncryption + Issuer: C=US, ST=California, O=Bumper, OU=Bumper, CN=Bumper + Validity + Not Before: Feb 1 06:17:18 2019 GMT + Not After : Jan 29 06:17:18 2029 GMT + Subject: CN=Bumper + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:d1:e1:2a:b2:59:da:25:c6:e1:a9:36:a5:54:c4: + 13:35:3f:3f:b3:af:79:ee:b2:d2:c3:bf:1c:65:25: + 50:10:84:8d:33:0c:92:e1:0e:6d:61:82:73:5d:71: + 26:ab:2a:7c:ac:13:16:00:0a:df:58:66:8f:3b:cb: + 20:2a:ce:ab:0b:d5:1c:62:ea:d5:af:51:9b:c6:db: + 15:75:14:51:23:f7:e0:b6:dd:ab:b5:64:18:76:40: + 48:ec:3c:fd:be:39:1c:50:5d:6d:5b:0c:9d:5b:33: + ed:2a:e9:f3:1c:6a:bd:5c:d5:98:f0:db:c9:e1:ea: + b9:76:73:b5:a0:5d:70:c8:f5:9e:e5:b6:ca:51:12: + 69:03:78:0e:5e:e0:ad:11:8f:93:3a:ff:be:f0:f2: + b7:bd:d3:d0:9e:41:35:a4:54:67:4d:f0:4f:e6:b9: + 9a:b6:74:5e:16:56:df:c4:25:02:c1:c9:ea:5f:96: + b4:f8:17:83:6a:7c:38:19:29:b8:9b:43:02:15:71: + 96:11:bd:3b:3f:ef:09:51:d5:0f:fb:6a:db:30:86: + 70:51:be:36:04:1c:ff:50:a0:5e:83:8d:74:be:0c: + 21:3c:84:b2:41:eb:56:e1:71:a7:dd:b2:c9:93:dd: + f7:18:53:ba:82:f3:94:f4:25:5d:cc:f7:71:44:f3: + cb:3d + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + X509v3 Subject Key Identifier: + 26:7E:4F:14:80:43:9E:EF:64:C2:61:6E:81:68:4B:6A:F2:4E:B5:40 + X509v3 Authority Key Identifier: + keyid:5E:39:C7:78:31:83:30:51:82:D9:88:9B:73:0B:72:EF:44:74:7B:D5 + + X509v3 Subject Alternative Name: + DNS:*.ecouser.net, DNS:*.ecovacs.com + X509v3 Key Usage: critical + Digital Signature, Key Encipherment + X509v3 Extended Key Usage: critical + TLS Web Server Authentication, TLS Web Client Authentication + Signature Algorithm: sha1WithRSAEncryption + 89:11:41:18:ac:22:0d:4a:08:31:ee:18:d4:cf:88:02:93:f0: + ef:53:f5:86:e1:b2:16:0e:8d:87:f9:e9:29:7a:aa:95:4e:d7: + 89:96:ac:1c:9a:19:8c:84:62:41:7a:b8:8c:46:07:8f:56:ae: + 18:31:52:cb:60:3f:01:57:4d:65:d6:5f:95:da:bc:c1:53:35: + fa:cc:c6:49:aa:fe:35:d3:05:15:2c:bd:9b:ac:f0:36:d7:71: + bc:ed:3e:f9:ac:c8:84:58:5e:f0:02:02:47:9c:0f:f3:f6:06: + 35:4d:3b:69:2f:bb:81:cd:ae:61:84:75:97:b1:c2:e7:e1:74: + 18:61:bc:c0:6b:2e:4d:f6:61:0d:36:72:3d:e2:ef:ef:e5:cf: + 7a:17:af:d6:7c:d2:a4:88:ca:b3:4e:27:7e:f3:da:96:33:ef: + 12:12:02:5a:31:70:82:e9:b6:e4:a9:57:d7:a4:b3:f6:3f:ea: + fa:63:6a:e6:b7:26:73:bb:c1:87:4e:e2:13:40:65:b9:04:3c: + c9:95:d7:8e:07:6f:9d:af:d2:36:ee:aa:5d:e5:f9:bb:cc:47: + 9c:ce:62:cf:28:2e:a3:58:8b:63:a2:e3:c3:f4:b0:69:98:aa: + 84:ff:a5:c2:52:8b:e8:b4:5c:2c:67:ef:63:b3:5c:42:95:6d: + 6b:d7:5b:11 +-----BEGIN CERTIFICATE----- +MIIDkzCCAnugAwIBAgIJAJm9RhZ1mb21MA0GCSqGSIb3DQEBBQUAMFUxCzAJBgNV +BAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMQ8wDQYDVQQKEwZCdW1wZXIxDzAN +BgNVBAsTBkJ1bXBlcjEPMA0GA1UEAxMGQnVtcGVyMB4XDTE5MDIwMTA2MTcxOFoX +DTI5MDEyOTA2MTcxOFowETEPMA0GA1UEAwwGQnVtcGVyMIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEA0eEqslnaJcbhqTalVMQTNT8/s6957rLSw78cZSVQ +EISNMwyS4Q5tYYJzXXEmqyp8rBMWAArfWGaPO8sgKs6rC9UcYurVr1GbxtsVdRRR +I/fgtt2rtWQYdkBI7Dz9vjkcUF1tWwydWzPtKunzHGq9XNWY8NvJ4eq5dnO1oF1w +yPWe5bbKURJpA3gOXuCtEY+TOv++8PK3vdPQnkE1pFRnTfBP5rmatnReFlbfxCUC +wcnqX5a0+BeDanw4GSm4m0MCFXGWEb07P+8JUdUP+2rbMIZwUb42BBz/UKBeg410 +vgwhPISyQetW4XGn3bLJk933GFO6gvOU9CVdzPdxRPPLPQIDAQABo4GpMIGmMAkG +A1UdEwQCMAAwHQYDVR0OBBYEFCZ+TxSAQ57vZMJhboFoS2ryTrVAMB8GA1UdIwQY +MBaAFF45x3gxgzBRgtmIm3MLcu9EdHvVMCcGA1UdEQQgMB6CDSouZWNvdXNlci5u +ZXSCDSouZWNvdmFjcy5jb20wDgYDVR0PAQH/BAQDAgWgMCAGA1UdJQEB/wQWMBQG +CCsGAQUFBwMBBggrBgEFBQcDAjANBgkqhkiG9w0BAQUFAAOCAQEAiRFBGKwiDUoI +Me4Y1M+IApPw71P1huGyFg6Nh/npKXqqlU7XiZasHJoZjIRiQXq4jEYHj1auGDFS +y2A/AVdNZdZfldq8wVM1+szGSar+NdMFFSy9m6zwNtdxvO0++azIhFhe8AICR5wP +8/YGNU07aS+7gc2uYYR1l7HC5+F0GGG8wGsuTfZhDTZyPeLv7+XPehev1nzSpIjK +s04nfvPaljPvEhICWjFwgum25KlX16Sz9j/q+mNq5rcmc7vBh07iE0BluQQ8yZXX +jgdvna/SNu6qXeX5u8xHnM5izyguo1iLY6Ljw/SwaZiqhP+lwlKL6LRcLGfvY7Nc +QpVta9dbEQ== +-----END CERTIFICATE----- diff --git a/certs/CA/index.txt b/certs/CA/index.txt new file mode 100644 index 0000000..613826e --- /dev/null +++ b/certs/CA/index.txt @@ -0,0 +1,2 @@ +V 290129055932Z 99BD46167599BDB4 unknown /C=US/ST=California/O=Bumper/OU=Bumper/CN=Bumper +V 290129061718Z 99BD46167599BDB5 unknown /CN=Bumper diff --git a/certs/CA/index.txt.attr b/certs/CA/index.txt.attr new file mode 100644 index 0000000..8f7e63a --- /dev/null +++ b/certs/CA/index.txt.attr @@ -0,0 +1 @@ +unique_subject = yes diff --git a/certs/CA/index.txt.attr.old b/certs/CA/index.txt.attr.old new file mode 100644 index 0000000..8f7e63a --- /dev/null +++ b/certs/CA/index.txt.attr.old @@ -0,0 +1 @@ +unique_subject = yes diff --git a/certs/CA/index.txt.old b/certs/CA/index.txt.old new file mode 100644 index 0000000..de82fa9 --- /dev/null +++ b/certs/CA/index.txt.old @@ -0,0 +1 @@ +V 290129055932Z 99BD46167599BDB4 unknown /C=US/ST=California/O=Bumper/OU=Bumper/CN=Bumper diff --git a/certs/CA/openssl.cnf b/certs/CA/openssl.cnf new file mode 100644 index 0000000..77bb39a --- /dev/null +++ b/certs/CA/openssl.cnf @@ -0,0 +1,180 @@ +HOME = . +RANDFILE = $ENV::HOME/.rnd + +#################################################################### +# CA Definition +[ ca ] +default_ca = CA_default # The default ca section + +#################################################################### +# Per the above, this is where we define CA values +[ CA_default ] + +dir = . # Where everything is kept +certs = $dir/certsdb # Where the issued certs are kept +new_certs_dir = $certs # default place for new certs. +database = $dir/index.txt # database index file. +certificate = $dir/cacert.pem # The CA certificate +private_key = $dir/private/cakey.pem# The private key +serial = $dir/serial # The current serial number +RANDFILE = $dir/private/.rand # private random number file + +crldir = $dir/crl +crlnumber = $dir/crlnumber # the current crl number +crl = $crldir/crl.pem # The current CRL + +# By default we use "user certificate" extensions when signing +x509_extensions = usr_cert # The extentions to add to the cert + +# Honor extensions requested of us +copy_extensions = copy + +# Comment out the following two lines for the "traditional" +# (and highly broken) format. +name_opt = ca_default # Subject Name options +cert_opt = ca_default # Certificate field options + +# Extensions to add to a CRL. Note: Netscape communicator chokes on V2 CRLs +# so this is commented out by default to leave a V1 CRL. +# crlnumber must also be commented out to leave a V1 CRL. +#crl_extensions = crl_ext +default_days = 3650 # how long to certify for +default_crl_days= 30 # how long before next CRL +default_md = sha1 # which md to use. +preserve = no # keep passed DN ordering + +# A few difference way of specifying how similar the request should look +# For type CA, the listed attributes must be the same, and the optional +# and supplied fields are just that :-) +policy = policy_anything + +#################################################################### +# The default policy for the CA when signing requests, requires some +# resemblence to the CA cert +# +[ policy_match ] +countryName = match # Must be the same as the CA +stateOrProvinceName = match # Must be the same as the CA +organizationName = match # Must be the same as the CA +organizationalUnitName = optional # not required +commonName = supplied # must be there, whatever it is +emailAddress = optional # not required + +#################################################################### +# An alternative policy not referred to anywhere in this file. Can +# be used by specifying '-policy policy_anything' to ca(8). +# +[ policy_anything ] +countryName = optional +stateOrProvinceName = optional +localityName = optional +organizationName = optional +organizationalUnitName = optional +commonName = supplied +emailAddress = optional + +#################################################################### +# This is where we define how to generate CSRs +[ req ] +default_bits = 2048 +default_keyfile = privkey.pem +distinguished_name = req_distinguished_name # where to get DN for reqs +attributes = req_attributes # req attributes +x509_extensions = v3_ca # The extentions to add to self signed certs +req_extensions = v3_req # The extensions to add to req's +prompt = no + +# This sets a mask for permitted string types. There are several options. +# default: PrintableString, T61String, BMPString. +# pkix : PrintableString, BMPString. +# utf8only: only UTF8Strings. +# nombstr : PrintableString, T61String (no BMPStrings or UTF8Strings). +# MASK:XXXX a literal mask value. +# WARNING: current versions of Netscape crash on BMPStrings or UTF8Strings +# so use this option with caution! +string_mask = nombstr + + +#################################################################### +# Per "req" section, this is where we define DN info +[ req_distinguished_name ] +countryName = Country Name (2 letter code) +countryName_default = US +countryName_min = 2 +countryName_max = 2 + +stateOrProvinceName = State or Province Name (full name) +stateOrProvinceName_default = California + +localityName = Locality Name (eg, city) +localityName_default = Hawthorne + +0.organizationName = Organization Name (eg, company) +0.organizationName_default = PhilNet + +organizationalUnitName = Organizational Unit Name (eg, section) + +commonName = Common Name (eg, YOUR name) +commonName_max = 64 + +emailAddress = Email Address +emailAddress_max = 64 + + +#################################################################### +# We don't want these, but the section must exist +[ req_attributes ] +#challengePassword = A challenge password +#challengePassword_min = 4 +#challengePassword_max = 20 +#unstructuredName = An optional company name + + + +#################################################################### +[ v3_req ] +keyUsage=critical,digitalSignature,keyEncipherment +extendedKeyUsage=critical,serverAuth,clientAuth +basicConstraints=critical,CA:false +subjectAltName = @alt_names +[ alt_names ] +DNS.0 = *.ecouser.net +DNS.1 = *.ecovacs.com + +#################################################################### +# Extensions for when we sign normal certs (specified as default) +[ usr_cert ] +basicConstraints = CA:false +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid,issuer +subjectAltName = @alt_names +[ alt_names ] +DNS.0 = *.ecouser.net +DNS.1 = *.ecovacs.com + +#################################################################### +# Same as above, but cert req already has SubjectAltName +[ usr_cert_has_san ] +basicConstraints = CA:false +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid,issuer +subjectAltName = @alt_names +[ alt_names ] +DNS.0 = *.ecouser.net +DNS.1 = *.ecovacs.com + +#################################################################### +# Extensions to use when signing a CA +[ v3_ca ] +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always,issuer:always +basicConstraints = CA:true +subjectAltName=email:move + +#################################################################### +# Same as above, but CA req already has SubjectAltName +[ v3_ca_has_san ] +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always,issuer:always +basicConstraints = CA:true + diff --git a/certs/CA/private/cakey.pem b/certs/CA/private/cakey.pem new file mode 100644 index 0000000..fd39a20 --- /dev/null +++ b/certs/CA/private/cakey.pem @@ -0,0 +1,30 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIFHzBJBgkqhkiG9w0BBQ0wPDAbBgkqhkiG9w0BBQwwDgQIzLScD02ODzQCAggA +MB0GCWCGSAFlAwQBKgQQzjzZ8LiXuLxb/Rx75kGXkwSCBNAmHUh4RTgy1bJmwZN7 +uyb4gGHtY9+ZvKZikibq7LpiwmDAwjjwguJz/HRsquq8fsZDpB+rV4hD4lB3lHuj +jqETzKst5qLTgP0cC9BS0S0P9f0/L7MEcwtiBkds6dVs4Ot6IDr4napC+6wQCBfr +ISrTN8+ls9IVghiS1eOrarFTy2ZxkXFM6PJczLd15/pbTm91etLWl1vnPCJn3MTc +vUvneAChKFiwaX4Cbx8bhQP11OBBNshM1zANSoGAdijRpvRCGfeOIvgLsbBl9N0d ++lV6iJR0TzOr4GquBDXor/y2Mofn6wYRwtSLRUgHgAxy1USosWVuXwrtGO4htPoR +aGt8V3h4XQ90VGAh6ez43/nmkVDgIj8VU9qZ+skCz7Y4TxPZYXUIcnG14KFj9lsk +r+uV6HmHyBKSlSkAE+D9uukQb71oIW83tsSn6I4ulcWaax6VsOS5n/Jrhf9V+Nz0 +e0CjrOJeLUSd2vTiyz03ho32nIrsGFeNDHhFmKzXNynjcW3z6Mx9/dSjqPHn8wFn +0dcVkq0GAZ+L8woNyDhr4e7Hhy6FEDhRX9bWz7FlswnBzym2AzU/zhagDrP2MIQ9 +o/cjAp3AoX70IIF7kIha8OXG4ew4tHpQIAj9E5SG5uEeD6gVTg7HB93dH+NEeLNR +F65wzQHPhf+V2zR7qlpCg3gqzyE9mvvTN/DMAJwASXWXK37ITIjcXhErH/2shHSK +jRwYuQiAWuhPrAr04EUoeHQFM6EAKDqBsFRr+/Ot34DqhAwAOlmkyCM/mw7IDl73 +pX/Ey9CQc2u2PS0zqYkDIzWE1Osek3FIF6JHSzaiRYODjaE6v15FLe7hZ56x6NMI +EDzpScp6HwMg6jsudbONrz3KwvXE6j4+yWpp8tgkZzwsIfnOMtWSrGgX4Oz9Y0Ep +jZ1yKDLQBjKZor2z4FBNze74tEnzJdBl59mQoN3ffFWHRlnkOhAZ+HsaXoIKB6AZ +3NPzlgGhOmtza6HKAwlseMgjpNH7EmeTLplr5nUlEMwwJ2+i8VGS4J9458vipcBm +50MbqaR8N8jAsanxyAbBF7gLPzsezN9Kzus99B6uB2nZIgZIsOeCeBLEZGzz9bUv +AiI3rM38nFkkKeHjJwqcYsO6LiLxdstnaHSveE7d6ugmN3+bNaZ+QCUDFlfELr4M +n5Eiwv3+hlCL8UBubacdQypfYvYI/z8rTdH+5eTVxBjeTCG0QmjDlYiuHcycL+q3 +iE3WxQ0yGpeJL6V985Ej/0SHkudTI49T5o+jcp6HFNS7xQelBGndpXGq9Xyc+OV/ +c+1zYWxz/sMFAbMkCF2pMMLfKL5EBkLBCpOdxHtWFt9JMxuhRfl3YzN81xSV1m0A +FtURjPUSwGqI7QFPl4R4MA1+0AdveZYprGo2nSUQpSoCW9WHl8U8tLIUNtew1MPA +v19h5DSqJ3tj+pPdxmFixoG+hBNk8FO1lJvH6P2an6lYVTlZ3/uDU5/DHTUTHuen +9x8FY3LQDQZA65QEnN092cUxXWw6cha+xXvwrwZGZiWXtuMBh/mfa4apxQ5+KY0N +/RCbIBctvLgijuC+xQw8GqBcG5L7urCOhnyZCXspWW7+loI7uZ0EfpNi7jY8BmAs +3S3MwMwwp+uvloikg3ZTUJmx/g== +-----END ENCRYPTED PRIVATE KEY----- diff --git a/certs/CA/private/key.pem b/certs/CA/private/key.pem new file mode 100644 index 0000000..f3ece38 --- /dev/null +++ b/certs/CA/private/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDR4SqyWdolxuGp +NqVUxBM1Pz+zr3nustLDvxxlJVAQhI0zDJLhDm1hgnNdcSarKnysExYACt9YZo87 +yyAqzqsL1Rxi6tWvUZvG2xV1FFEj9+C23au1ZBh2QEjsPP2+ORxQXW1bDJ1bM+0q +6fMcar1c1Zjw28nh6rl2c7WgXXDI9Z7ltspREmkDeA5e4K0Rj5M6/77w8re909Ce +QTWkVGdN8E/muZq2dF4WVt/EJQLByepflrT4F4NqfDgZKbibQwIVcZYRvTs/7wlR +1Q/7atswhnBRvjYEHP9QoF6DjXS+DCE8hLJB61bhcafdssmT3fcYU7qC85T0JV3M +93FE88s9AgMBAAECggEBAJ4N3aOWeuv8DDDsUDG3v2dYDFb7DO4viP2UzIN3SAZI +bDVHgM0vmjvWi56Q+bfpNR44+VZq2auvAKrc24qhPLqacNglVWrEIHXuwwf6luhp +YbcU1JyzlqBm5QPTIpvAUoxK73Gj3FS0lCS/Li/2SkjBKn4oZq5Vn/mORfdQpwEO +aY5YzNNCzCwbqL/XcwvN1XRpF9puhBb+o2TtfGPLBf5LiScJWxrtPM7q+dFG/Ef7 +f9L28iX+W6e3lD0/d93XEtlh1IsaZTWkl4m01Etg0f2FFtcja38eG+Yys4YNSpPe +VUWtFL2eDX+u5wv7nbaG9GsZmithXL6xa0PdwOd0KAECgYEA/D7K2KcxS+HeBgcG +V3b0m6QH1bRbqRxzK22TKnCVLAlRW7+5bfK56sHqMrOnDp/PuyAdq93roeJBNnAg +DrUt9i/lFTZ2kSBTJIlKW7cHv2Hocw3sQSDY/PhAXtALULxa8NHadPGsntHQDLzq +TVUNhOU6+GqPcqTp8BeSy+XrGD0CgYEA1QDvbxh/5tQxCFXcKn5KQCpJhbCfKBSW +drd8YF3Gdpxt3d2vTFTOsQ3xyAvEhSV0Jy9WUXobmx+KK8OjZqsR4PoYpYN3IWIW +N9jBKE9bahPRFF3g3FxaeVrCBkZBVK+ZAv/E5gshCyCXOmgHT+Kgi1Xpb4X0J/Pj +T83O7y/DrwECgYEAlagfq4kOy2m307WpQo12NT2N1HIMi/owLoz1apGfP6BLn/bR +anU/bxQcAnbqX9fbZLd6QzUIj2Rwsn611mQSX+Ussng8wn/+NEgsUNv4AclyI4kY +Kc/2TahwcJBYlk8aWZT4WrfkZdov6M7Q6I7U1Ys3gJmggDFuBp/F3amM4TUCgYBv +IofKvUuukjPrF3FCMpZnGCk4Ockwzl0bH+0jEoqERGYGhq35853MP2xRZeJpXftW +npo49K8GptQetXrCDKvNaHAHm6pCnrzc8pkFgTI/DIvgRoMWNpF2E+Z8A5f+qYf6 +SpzfMhtRhPWiobh+8820JWUgrRGQ7u1BLC62GYPOAQKBgG/rt2Xu42DJcQ/wm7wN +LB8zn54G/rKWq+mXQbenpr1lN4zBOGgZILrRb1HTcQ7a19056BHBmx3J52yQAnGH +cxuhOyppsH3WgUkUqYN4bzO1CJorX3cP9udBAcmN4BWLF7pLgT7zHrLkJg05sWnd +UHWm2y7vdXeTvdD381p0Ug0S +-----END PRIVATE KEY----- diff --git a/certs/CA/serial b/certs/CA/serial new file mode 100644 index 0000000..3bf84d2 --- /dev/null +++ b/certs/CA/serial @@ -0,0 +1 @@ +99BD46167599BDB6 diff --git a/certs/CA/serial.old b/certs/CA/serial.old new file mode 100644 index 0000000..9d0f0bc --- /dev/null +++ b/certs/CA/serial.old @@ -0,0 +1 @@ +99BD46167599BDB5 diff --git a/certs/cert.pem b/certs/cert.pem index 87925f8..64ccbbf 100644 --- a/certs/cert.pem +++ b/certs/cert.pem @@ -1,33 +1,85 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 11078087720677522869 (0x99bd46167599bdb5) + Signature Algorithm: sha1WithRSAEncryption + Issuer: C=US, ST=California, O=Bumper, OU=Bumper, CN=Bumper + Validity + Not Before: Feb 1 06:17:18 2019 GMT + Not After : Jan 29 06:17:18 2029 GMT + Subject: CN=Bumper + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:d1:e1:2a:b2:59:da:25:c6:e1:a9:36:a5:54:c4: + 13:35:3f:3f:b3:af:79:ee:b2:d2:c3:bf:1c:65:25: + 50:10:84:8d:33:0c:92:e1:0e:6d:61:82:73:5d:71: + 26:ab:2a:7c:ac:13:16:00:0a:df:58:66:8f:3b:cb: + 20:2a:ce:ab:0b:d5:1c:62:ea:d5:af:51:9b:c6:db: + 15:75:14:51:23:f7:e0:b6:dd:ab:b5:64:18:76:40: + 48:ec:3c:fd:be:39:1c:50:5d:6d:5b:0c:9d:5b:33: + ed:2a:e9:f3:1c:6a:bd:5c:d5:98:f0:db:c9:e1:ea: + b9:76:73:b5:a0:5d:70:c8:f5:9e:e5:b6:ca:51:12: + 69:03:78:0e:5e:e0:ad:11:8f:93:3a:ff:be:f0:f2: + b7:bd:d3:d0:9e:41:35:a4:54:67:4d:f0:4f:e6:b9: + 9a:b6:74:5e:16:56:df:c4:25:02:c1:c9:ea:5f:96: + b4:f8:17:83:6a:7c:38:19:29:b8:9b:43:02:15:71: + 96:11:bd:3b:3f:ef:09:51:d5:0f:fb:6a:db:30:86: + 70:51:be:36:04:1c:ff:50:a0:5e:83:8d:74:be:0c: + 21:3c:84:b2:41:eb:56:e1:71:a7:dd:b2:c9:93:dd: + f7:18:53:ba:82:f3:94:f4:25:5d:cc:f7:71:44:f3: + cb:3d + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + X509v3 Subject Key Identifier: + 26:7E:4F:14:80:43:9E:EF:64:C2:61:6E:81:68:4B:6A:F2:4E:B5:40 + X509v3 Authority Key Identifier: + keyid:5E:39:C7:78:31:83:30:51:82:D9:88:9B:73:0B:72:EF:44:74:7B:D5 + + X509v3 Subject Alternative Name: + DNS:*.ecouser.net, DNS:*.ecovacs.com + X509v3 Key Usage: critical + Digital Signature, Key Encipherment + X509v3 Extended Key Usage: critical + TLS Web Server Authentication, TLS Web Client Authentication + Signature Algorithm: sha1WithRSAEncryption + 89:11:41:18:ac:22:0d:4a:08:31:ee:18:d4:cf:88:02:93:f0: + ef:53:f5:86:e1:b2:16:0e:8d:87:f9:e9:29:7a:aa:95:4e:d7: + 89:96:ac:1c:9a:19:8c:84:62:41:7a:b8:8c:46:07:8f:56:ae: + 18:31:52:cb:60:3f:01:57:4d:65:d6:5f:95:da:bc:c1:53:35: + fa:cc:c6:49:aa:fe:35:d3:05:15:2c:bd:9b:ac:f0:36:d7:71: + bc:ed:3e:f9:ac:c8:84:58:5e:f0:02:02:47:9c:0f:f3:f6:06: + 35:4d:3b:69:2f:bb:81:cd:ae:61:84:75:97:b1:c2:e7:e1:74: + 18:61:bc:c0:6b:2e:4d:f6:61:0d:36:72:3d:e2:ef:ef:e5:cf: + 7a:17:af:d6:7c:d2:a4:88:ca:b3:4e:27:7e:f3:da:96:33:ef: + 12:12:02:5a:31:70:82:e9:b6:e4:a9:57:d7:a4:b3:f6:3f:ea: + fa:63:6a:e6:b7:26:73:bb:c1:87:4e:e2:13:40:65:b9:04:3c: + c9:95:d7:8e:07:6f:9d:af:d2:36:ee:aa:5d:e5:f9:bb:cc:47: + 9c:ce:62:cf:28:2e:a3:58:8b:63:a2:e3:c3:f4:b0:69:98:aa: + 84:ff:a5:c2:52:8b:e8:b4:5c:2c:67:ef:63:b3:5c:42:95:6d: + 6b:d7:5b:11 -----BEGIN CERTIFICATE----- -MIIFtTCCA52gAwIBAgIJAKLG5udzkR+zMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV -BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX -aWRnaXRzIFB0eSBMdGQwHhcNMTcxMjA1MTkxNTQwWhcNMTgxMjA1MTkxNTQwWjBF -MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 -ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC -CgKCAgEAu1z0Iw7KLJZD8CBrZWm9Jp7eqIAqdoTn3GWmNmzgIDnzmxZb70War74d -7aWDVtEVtOZQ8ShW15zRanr6hQkHdM5Li1KTMYW3P7TOmzJZkESNEZviOyvlfRPG -oLLbPIp+A7W8Zz3ST0cDzyy6N7LRAA0OywRgPhrxrN637Fx1+SFwqKQg0IUOuX3v -veZ1oAZSBUDzpDPUaak7jXugB11wt6XTEekwOcrH7WEDoFH87thtJyCM7IidW0bM -rkqxeiM18qMzn8cGqnm2nY+VHgK0t0/QZ/cftPVc2IHZLr/+eTGaJmPv7gL+7v/l -7fgk3RMTn0GrdTO+Vvnu1qNK7r+//KMgGOo6zwEQAI9Ml6y1LRuEXrymgsYY9qCE -ZtBV0FoZaVYAuGG4cvCDLLPgM2OivIcWgG+77MqMH/RkWg+BrUesn7ISEpF+7Q7+ -jc6pFYr4y5fc2jJ9/c5Mxu6sv0Tc8N/c9WpcXsJ6Y1JrBbDcdGSwmTZ1JVyiog9O -6bU/16QTuU3w2oH9qjnc907tZuB66P53WJ2iHPvB8yF5V93FvtbPYsHpO7noURSI -LqvKoNy31ndv4kao2AIzrJuhBBkSorBEcoFerRRBJMHa85R5Tb6iuGg6RFv0BB8K -gv6mG/7oqmR8QDnRRd54U2pEsIkYmgoIU4/8L91r9Vd1eiEDEnsCAwEAAaOBpzCB -pDAdBgNVHQ4EFgQUqA/GBsZAepFIcbgw6gK/HTUWolswdQYDVR0jBG4wbIAUqA/G -BsZAepFIcbgw6gK/HTUWoluhSaRHMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpT -b21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGSCCQCi -xubnc5EfszAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4ICAQB7kHEHKNDQ -TcvwY9z3RscpHyWwjrGjUdjk8AkM9cTm/sT8G7JU/7+KZueu4elNOc4hir/+DMlO -/AN6IXRNrfPO8IVG6hKnK9K3wvceJzg6mT8F4peg4vMNdIsrZyQaPhWArUF7LG/Q -HF4Yk8jnw3NKIG5fnlagAGFDdDGSdDK/IQN+JA6sHJVQRrBLpksRUCWFY7QJGYVS -DmEAYr6Y6emftbflUjW6iC/E57lC5F7bQyoIEIBBHKjMaNTdM8TzOUZSOGLwAuVD -C1iAoZn7VWeGa4h/xwx0yio0yeI6gRh3fINBKoCNv6pLUm6sgrW8oqGMDJiH0T3H -sc8xC3bK2DPO4sIyisL3HUPcsSFB/hf0i8BOQrihrpZEn/fx4lTRyQYSKT5NVSl4 -v/9hmDjWIXh9EInGfP8DdWp4Og8GiXcV9rUqvRBo5aBwqK/aTNRCk6ah5N958yFu -wHlrbQmPnw1AqJSG1oG+4dcXnrcgd/xGMAarTbgzqsd8icafg8v2x2pG5ZexE3uN -0METK9hvCrjN8/Rvo2ViTDa8WF4CXMuVFG4FdbeRrYPZl1j1jBYDMtKkVJJCsVQd -chYbqKgNDmMcbfpksBtF3XpYTtVNAKA9g5mrAhEJS88uy63PStLoBQNXv9y8ksql -VVCTFNGy5JRyvOeSRYSSdXjKbfj5Vgh1HQ== +MIIDkzCCAnugAwIBAgIJAJm9RhZ1mb21MA0GCSqGSIb3DQEBBQUAMFUxCzAJBgNV +BAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMQ8wDQYDVQQKEwZCdW1wZXIxDzAN +BgNVBAsTBkJ1bXBlcjEPMA0GA1UEAxMGQnVtcGVyMB4XDTE5MDIwMTA2MTcxOFoX +DTI5MDEyOTA2MTcxOFowETEPMA0GA1UEAwwGQnVtcGVyMIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEA0eEqslnaJcbhqTalVMQTNT8/s6957rLSw78cZSVQ +EISNMwyS4Q5tYYJzXXEmqyp8rBMWAArfWGaPO8sgKs6rC9UcYurVr1GbxtsVdRRR +I/fgtt2rtWQYdkBI7Dz9vjkcUF1tWwydWzPtKunzHGq9XNWY8NvJ4eq5dnO1oF1w +yPWe5bbKURJpA3gOXuCtEY+TOv++8PK3vdPQnkE1pFRnTfBP5rmatnReFlbfxCUC +wcnqX5a0+BeDanw4GSm4m0MCFXGWEb07P+8JUdUP+2rbMIZwUb42BBz/UKBeg410 +vgwhPISyQetW4XGn3bLJk933GFO6gvOU9CVdzPdxRPPLPQIDAQABo4GpMIGmMAkG +A1UdEwQCMAAwHQYDVR0OBBYEFCZ+TxSAQ57vZMJhboFoS2ryTrVAMB8GA1UdIwQY +MBaAFF45x3gxgzBRgtmIm3MLcu9EdHvVMCcGA1UdEQQgMB6CDSouZWNvdXNlci5u +ZXSCDSouZWNvdmFjcy5jb20wDgYDVR0PAQH/BAQDAgWgMCAGA1UdJQEB/wQWMBQG +CCsGAQUFBwMBBggrBgEFBQcDAjANBgkqhkiG9w0BAQUFAAOCAQEAiRFBGKwiDUoI +Me4Y1M+IApPw71P1huGyFg6Nh/npKXqqlU7XiZasHJoZjIRiQXq4jEYHj1auGDFS +y2A/AVdNZdZfldq8wVM1+szGSar+NdMFFSy9m6zwNtdxvO0++azIhFhe8AICR5wP +8/YGNU07aS+7gc2uYYR1l7HC5+F0GGG8wGsuTfZhDTZyPeLv7+XPehev1nzSpIjK +s04nfvPaljPvEhICWjFwgum25KlX16Sz9j/q+mNq5rcmc7vBh07iE0BluQQ8yZXX +jgdvna/SNu6qXeX5u8xHnM5izyguo1iLY6Ljw/SwaZiqhP+lwlKL6LRcLGfvY7Nc +QpVta9dbEQ== -----END CERTIFICATE----- diff --git a/certs/cert.pem.bak b/certs/cert.pem.bak new file mode 100644 index 0000000..87925f8 --- /dev/null +++ b/certs/cert.pem.bak @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFtTCCA52gAwIBAgIJAKLG5udzkR+zMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTcxMjA1MTkxNTQwWhcNMTgxMjA1MTkxNTQwWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC +CgKCAgEAu1z0Iw7KLJZD8CBrZWm9Jp7eqIAqdoTn3GWmNmzgIDnzmxZb70War74d +7aWDVtEVtOZQ8ShW15zRanr6hQkHdM5Li1KTMYW3P7TOmzJZkESNEZviOyvlfRPG +oLLbPIp+A7W8Zz3ST0cDzyy6N7LRAA0OywRgPhrxrN637Fx1+SFwqKQg0IUOuX3v +veZ1oAZSBUDzpDPUaak7jXugB11wt6XTEekwOcrH7WEDoFH87thtJyCM7IidW0bM +rkqxeiM18qMzn8cGqnm2nY+VHgK0t0/QZ/cftPVc2IHZLr/+eTGaJmPv7gL+7v/l +7fgk3RMTn0GrdTO+Vvnu1qNK7r+//KMgGOo6zwEQAI9Ml6y1LRuEXrymgsYY9qCE +ZtBV0FoZaVYAuGG4cvCDLLPgM2OivIcWgG+77MqMH/RkWg+BrUesn7ISEpF+7Q7+ +jc6pFYr4y5fc2jJ9/c5Mxu6sv0Tc8N/c9WpcXsJ6Y1JrBbDcdGSwmTZ1JVyiog9O +6bU/16QTuU3w2oH9qjnc907tZuB66P53WJ2iHPvB8yF5V93FvtbPYsHpO7noURSI +LqvKoNy31ndv4kao2AIzrJuhBBkSorBEcoFerRRBJMHa85R5Tb6iuGg6RFv0BB8K +gv6mG/7oqmR8QDnRRd54U2pEsIkYmgoIU4/8L91r9Vd1eiEDEnsCAwEAAaOBpzCB +pDAdBgNVHQ4EFgQUqA/GBsZAepFIcbgw6gK/HTUWolswdQYDVR0jBG4wbIAUqA/G +BsZAepFIcbgw6gK/HTUWoluhSaRHMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpT +b21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGSCCQCi +xubnc5EfszAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4ICAQB7kHEHKNDQ +TcvwY9z3RscpHyWwjrGjUdjk8AkM9cTm/sT8G7JU/7+KZueu4elNOc4hir/+DMlO +/AN6IXRNrfPO8IVG6hKnK9K3wvceJzg6mT8F4peg4vMNdIsrZyQaPhWArUF7LG/Q +HF4Yk8jnw3NKIG5fnlagAGFDdDGSdDK/IQN+JA6sHJVQRrBLpksRUCWFY7QJGYVS +DmEAYr6Y6emftbflUjW6iC/E57lC5F7bQyoIEIBBHKjMaNTdM8TzOUZSOGLwAuVD +C1iAoZn7VWeGa4h/xwx0yio0yeI6gRh3fINBKoCNv6pLUm6sgrW8oqGMDJiH0T3H +sc8xC3bK2DPO4sIyisL3HUPcsSFB/hf0i8BOQrihrpZEn/fx4lTRyQYSKT5NVSl4 +v/9hmDjWIXh9EInGfP8DdWp4Og8GiXcV9rUqvRBo5aBwqK/aTNRCk6ah5N958yFu +wHlrbQmPnw1AqJSG1oG+4dcXnrcgd/xGMAarTbgzqsd8icafg8v2x2pG5ZexE3uN +0METK9hvCrjN8/Rvo2ViTDa8WF4CXMuVFG4FdbeRrYPZl1j1jBYDMtKkVJJCsVQd +chYbqKgNDmMcbfpksBtF3XpYTtVNAKA9g5mrAhEJS88uy63PStLoBQNXv9y8ksql +VVCTFNGy5JRyvOeSRYSSdXjKbfj5Vgh1HQ== +-----END CERTIFICATE----- diff --git a/certs/key.pem b/certs/key.pem index 852040d..f3ece38 100644 --- a/certs/key.pem +++ b/certs/key.pem @@ -1,51 +1,28 @@ ------BEGIN RSA PRIVATE KEY----- -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 ------END RSA PRIVATE KEY----- +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDR4SqyWdolxuGp +NqVUxBM1Pz+zr3nustLDvxxlJVAQhI0zDJLhDm1hgnNdcSarKnysExYACt9YZo87 +yyAqzqsL1Rxi6tWvUZvG2xV1FFEj9+C23au1ZBh2QEjsPP2+ORxQXW1bDJ1bM+0q +6fMcar1c1Zjw28nh6rl2c7WgXXDI9Z7ltspREmkDeA5e4K0Rj5M6/77w8re909Ce +QTWkVGdN8E/muZq2dF4WVt/EJQLByepflrT4F4NqfDgZKbibQwIVcZYRvTs/7wlR +1Q/7atswhnBRvjYEHP9QoF6DjXS+DCE8hLJB61bhcafdssmT3fcYU7qC85T0JV3M +93FE88s9AgMBAAECggEBAJ4N3aOWeuv8DDDsUDG3v2dYDFb7DO4viP2UzIN3SAZI +bDVHgM0vmjvWi56Q+bfpNR44+VZq2auvAKrc24qhPLqacNglVWrEIHXuwwf6luhp +YbcU1JyzlqBm5QPTIpvAUoxK73Gj3FS0lCS/Li/2SkjBKn4oZq5Vn/mORfdQpwEO +aY5YzNNCzCwbqL/XcwvN1XRpF9puhBb+o2TtfGPLBf5LiScJWxrtPM7q+dFG/Ef7 +f9L28iX+W6e3lD0/d93XEtlh1IsaZTWkl4m01Etg0f2FFtcja38eG+Yys4YNSpPe +VUWtFL2eDX+u5wv7nbaG9GsZmithXL6xa0PdwOd0KAECgYEA/D7K2KcxS+HeBgcG +V3b0m6QH1bRbqRxzK22TKnCVLAlRW7+5bfK56sHqMrOnDp/PuyAdq93roeJBNnAg +DrUt9i/lFTZ2kSBTJIlKW7cHv2Hocw3sQSDY/PhAXtALULxa8NHadPGsntHQDLzq +TVUNhOU6+GqPcqTp8BeSy+XrGD0CgYEA1QDvbxh/5tQxCFXcKn5KQCpJhbCfKBSW +drd8YF3Gdpxt3d2vTFTOsQ3xyAvEhSV0Jy9WUXobmx+KK8OjZqsR4PoYpYN3IWIW +N9jBKE9bahPRFF3g3FxaeVrCBkZBVK+ZAv/E5gshCyCXOmgHT+Kgi1Xpb4X0J/Pj +T83O7y/DrwECgYEAlagfq4kOy2m307WpQo12NT2N1HIMi/owLoz1apGfP6BLn/bR +anU/bxQcAnbqX9fbZLd6QzUIj2Rwsn611mQSX+Ussng8wn/+NEgsUNv4AclyI4kY +Kc/2TahwcJBYlk8aWZT4WrfkZdov6M7Q6I7U1Ys3gJmggDFuBp/F3amM4TUCgYBv +IofKvUuukjPrF3FCMpZnGCk4Ockwzl0bH+0jEoqERGYGhq35853MP2xRZeJpXftW +npo49K8GptQetXrCDKvNaHAHm6pCnrzc8pkFgTI/DIvgRoMWNpF2E+Z8A5f+qYf6 +SpzfMhtRhPWiobh+8820JWUgrRGQ7u1BLC62GYPOAQKBgG/rt2Xu42DJcQ/wm7wN +LB8zn54G/rKWq+mXQbenpr1lN4zBOGgZILrRb1HTcQ7a19056BHBmx3J52yQAnGH +cxuhOyppsH3WgUkUqYN4bzO1CJorX3cP9udBAcmN4BWLF7pLgT7zHrLkJg05sWnd +UHWm2y7vdXeTvdD381p0Ug0S +-----END PRIVATE KEY----- diff --git a/certs/key.pem.bak b/certs/key.pem.bak new file mode 100644 index 0000000..852040d --- /dev/null +++ b/certs/key.pem.bak @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- From e3179d36a8aabce325f91c4668405ccc232337d1 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Fri, 1 Feb 2019 17:19:07 -0500 Subject: [PATCH 06/34] Change Confserver to AIOHTTP Change Confserver to AIOHTTP Better threading and performance across --- Pipfile | 1 + bumper.py | 12 +- bumper/confserver.py | 444 +++++++++++++++++++------------------------ bumper/mqttserver.py | 70 ++++--- bumper/xmppserver.py | 2 + 5 files changed, 255 insertions(+), 274 deletions(-) diff --git a/Pipfile b/Pipfile index fff0c8b..db09990 100644 --- a/Pipfile +++ b/Pipfile @@ -6,5 +6,6 @@ name = "pypi" [packages] hbmqtt = "*" paho-mqtt = "*" +aiohttp = "*" [dev-packages] diff --git a/bumper.py b/bumper.py index 5f004d7..6c086c9 100644 --- a/bumper.py +++ b/bumper.py @@ -5,8 +5,15 @@ import bumper import sys, socket import time -logging.basicConfig(level=logging.INFO, - format='%(asctime)s %(levelname)-8s %(message)s') + +args = sys.argv +if len(args) > 0: + if '--debug' in args: + logging.basicConfig(level=logging.DEBUG, + format='%(asctime)s %(levelname)-8s %(message)s') + else: + logging.basicConfig(level=logging.INFO, + format='%(asctime)s %(levelname)-8s %(message)s') #conf_address = (socket.gethostbyname(socket.gethostname()), 443) conf_address = ("0.0.0.0", 443) @@ -32,4 +39,3 @@ conf_server = bumper.ConfServer(conf_address, usessl=True, run_async=True, bumpe # start xmpp server (sync) xmpp_server = bumper.XMPPServer(xmpp_address) -conf_server.disconnect() diff --git a/bumper/confserver.py b/bumper/confserver.py index 78e11c7..956af3f 100644 --- a/bumper/confserver.py +++ b/bumper/confserver.py @@ -1,270 +1,222 @@ #!/usr/bin/env python3 -from http.server import HTTPServer -from http.server import BaseHTTPRequestHandler -from http import HTTPStatus from threading import Thread -import socket, logging, ssl, json, sys -import asyncio -import contextvars +import socket, logging, ssl, json import string import random import bumper import time from datetime import datetime, timedelta -import threading -from socketserver import ThreadingMixIn - -class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): - """Handle requests in a separate thread.""" - -class RequestHandler(BaseHTTPRequestHandler): - bumper_clients = contextvars.ContextVar - helperbot = object - - def do_POST(self): - try: - self.protocol_version = 'HTTP/1.1' - content_length = int(self.headers['Content-Length']) - post_data = self.rfile.read(content_length) - logging.debug("Headers: " + str(self.headers)) - body = {} - if "app_logs" in str(self.path): - body = {} - - else: - request_body = post_data.decode('utf-8') - logging.debug("Request: " + request_body) - json_body = json.loads(request_body) - - if "/product/getProductIotMap" in str(self.path): - psplit = str(self.path).split("/") - logging.debug("getProductIotMap Path Split: %s" %psplit) - body = {"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]} - elif "notify_engine.do" in str(self.path): - psplit = str(self.path).split("/") - logging.debug("notify_engine Path Split: %s" %psplit) - body = {"ret":"ok"} - elif "iot/devmanager.do" in str(self.path): #Handle rest commands - psplit = str(self.path).split("/") - logging.debug("devmanager Path Split: %s" %psplit) - randomid = ''.join(random.sample(string.ascii_letters,6)) - retcmd = self.helperbot.send_command(json_body, randomid) - body = retcmd - elif "/users/user.do" in str(self.path): #Handle user commands - psplit = str(self.path).split("/") - logging.debug("userdo Path Split: %s" %psplit) - todo = json_body['todo'] - if todo == 'FindBest': - service = json_body['service'] - if service == 'EcoMsgNew': - body = {"result":"ok","ip":socket.gethostbyname(socket.gethostname()),"port":5223} - elif service == 'EcoUpdate': - body = {"result":"ok","ip":"47.88.66.164","port":8005} - elif todo == 'loginByItToken': - psplit = str(self.path).split("/") - logging.debug("LoginByItToken Split: %s" %psplit) - body = { - "resource": json_body["resource"], - "result": "ok", - "todo": "result", - "token": json_body["token"], #RandomChar(32) - "userId": json_body["userId"] #RandomChar(16) - } - #TODO: Randomize - #self.send_header("ETag", "ETag1234") - elif todo == 'GetDeviceList': - active_bots = self.bumper_clients.get() - body = { - "devices": active_bots, - "result": "ok", - "todo": "result" - } - - elif "ESTInt" in str(self.path): #Handle user commands: - psplit = str(self.path).split("/") - logging.debug("bigdata Path Split: %s" %psplit) - {"header":{"result_code":"000000"},"body":{}} - else: - psplit = str(self.path).split("/") - logging.debug("Other Post Path Split: %s" %psplit) - body = {} - - body = json.dumps(body) - logging.debug("Response: " + str(body)) - body = body.encode(encoding='UTF-8') - self.send_response(HTTPStatus.OK) - self.send_header('Content-Type', 'application/json; charset=utf-8') - self.send_header('Connection', 'keep-alive') - self.send_header('Content-Length', len(body)) - self.end_headers() - - self.wfile.write(body) - except Exception as e: - logging.error('ConfServer POST: {}'.format(e)) - - def do_GET(self): - try: - self.protocol_version = 'HTTP/1.1' - logging.debug("Path: " + self.path) - - if "/user/login?" in str(self.path): - psplit = str(self.path).split("/") - logging.debug("Login Path Split: %s" %psplit) - #Could implement basic auth if you wanted, or just accept anything - body = { - "code": "0000", - "data": { - "accessToken": "tempaccesstoken", #Random chars 32 length - "country": psplit[3], - "email": "null@null.com", - "uid": "fuid_1", #Date(14)_RandomChars(32) - "username": "fusername_1" #Random chars 8 - }, - "msg": "操作成功", - "time": bumper.current_milli_time() - } - - elif "/user/getAuthCode?" in str(self.path): - psplit = str(self.path).split("/") - logging.debug("getAuthCode Path Split: %s" %psplit) - body = { - "code": "0000", - "data": { - "authCode": "{}_tempauthcode".format(psplit[3]), #countrycode_randomchars(32) - "ecovacsUid": "fuid_1" #Date(14)_RandomChars(32) - }, - "msg": "操作成功", - "time": bumper.current_milli_time() - } - - elif "/common/checkVersion?" in str(self.path): - psplit = str(self.path).split("/") - logging.debug("checkVersion Path Split: %s" %psplit) - body = { - "code": "0000", - "data": { - "c": None, - "img": None, - "r": 0, - "t": None, - "u": None, - "ut": 0, - "v": None - }, - "msg": "操作成功", - "time": bumper.current_milli_time() - } - - - elif "/user/checkAgreement?" in str(self.path): - psplit = str(self.path).split("/") - logging.debug("checkAgreement Path Split: %s" %psplit) - body = { - "code": "0000", - "data": [], - "msg": "操作成功", - "time": bumper.current_milli_time() - - } - elif "/campaign/homePageAlert?" in str(self.path): - psplit = str(self.path).split("/") - logging.debug("homePageAlert Path Split: %s" %psplit) - nextAlert = bumper.get_milli_time((datetime.now() + timedelta(hours=12)).timestamp()) - logging.debug("Next Alert %s" % nextAlert) - body = { - "code": "0000", - "data": { - "clickSchemeUrl": None, - "clickWebUrl": None, - "hasCampaign": "N", - "imageUrl": None, - "nextAlertTime": nextAlert, - "serverTime": bumper.current_milli_time() - }, - "msg": "操作成功", - "time": bumper.current_milli_time() - } - - - elif "/user/logout?" in str(self.path): - psplit = str(self.path).split("/") - logging.debug("Logout Path Split: %s" %psplit) - body = {"code": "0000","data": None,"msg": "操作成功", "time": bumper.current_milli_time()} - else: - psplit = str(self.path).split("/") - logging.debug("Other Get Path Split: %s" %psplit) - body = {} - - body = json.dumps(body) - logging.debug("Response: " + str(body)) - body = body.encode(encoding='UTF-8') - self.send_response(HTTPStatus.OK) - self.send_header('Content-Type', 'application/json; charset=utf-8') - self.send_header('Connection', 'keep-alive') - self.send_header('Content-Length', len(body)) - self.end_headers() - self.wfile.write(body) - except Exception as e: - logging.error('ConfServer GET: {}'.format(e)) - - -class HTTPServerThread(ThreadedHTTPServer, Thread): - bumper_clients = contextvars.ContextVar - def __init__(self, server_address, bumper_clients, helperbot): - Thread.__init__(self) - self.server_address = server_address - self.bumper_clients = bumper_clients - self.helperbot = helperbot - self.handler = RequestHandler - self.handler.bumper_clients = self.bumper_clients - self.handler.helperbot = self.helperbot - - self.exit_flag = False - - ThreadedHTTPServer.__init__(self, self.server_address, self.handler) - - def handle_error(self, request, client_address): - self.close_request(request) - def run(self): - try: - logging.info('ConfServer: listening on {}:{}'.format(self.server_address[0], self.server_address[1])) - while not self.exit_flag: - self.handle_request() - except Exception as e: - logging.error('ConfServer: {}'.format(e)) - def disconnect(self): - self.exit_flag = True - # make a connection to - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.connect(self.server_address) - s.close() - +import asyncio +import contextvars +from aiohttp import web class ConfServer(): bumper_clients = contextvars.ContextVar - + def __init__(self, address, usessl=False, run_async=True, bumper_clients=contextvars.ContextVar, helperbot=None): - self.run_async = run_async - self.server = HTTPServerThread(address, bumper_clients, helperbot) + self.bumper_clients = bumper_clients + self.helperbot = helperbot + self.usessl = usessl + self.run_async = run_async + self.address = address - try: - if usessl: - self.server.socket = ssl.wrap_socket(self.server.socket, keyfile='./certs/key.pem', certfile='./certs/cert.pem', server_side=True) - if self.run_async: - self.server.start() + + try: + if run_async: + logging.debug("Starting ConfServer Thread: 1") + confserver = Thread(name="ConfServer_Thread",target=self.run_server) + self.server = confserver + confserver.setDaemon(True) + confserver.start() else: try: - self.server.run() + self.run_server() except KeyboardInterrupt: self.disconnect() except Exception as e: logging.error('ConfServer: {}'.format(e)) + + def run_server(self): + try: + loop = asyncio.get_event_loop() + except: + loop = asyncio.new_event_loop() + + loop.run_until_complete(self.start_server()) + loop.run_forever() + + + async def start_server(self): + #formatter = "[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s" + #logging.basicConfig(level=logging.INFO, format=formatter) + app = web.Application() + + app.add_routes([ + web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/login', self.handle_login), + web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/logout', self.handle_login), + web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/getAuthCode', self.handle_getAuthCode), + web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/checkAgreement', self.handle_checkAgreement), + web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/common/getAuthCode', self.handle_checkVersion), + web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/campaign/homePageAlert', self.handle_homePageAlert), + + web.post('/api/users/user.do', self.handle_usersapi), + web.post('/api/pim/product/getProductIotMap', self.handle_getProductIotMap), + web.post('/api/iot/devmanager.do', self.handle_devmanager) + ]) + + + runner = web.AppRunner(app, access_log=None) + await runner.setup() + + if self.usessl: + ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + ssl_ctx.load_cert_chain('./certs/cert.pem', './certs/key.pem') + site = web.TCPSite(runner, host=self.address[0], port=self.address[1],ssl_context=ssl_ctx) + + else: + site = web.TCPSite(runner, host=self.address[0], port=self.address[1]) + + await site.start() + + + async def handle_login(self, request): + #Could implement basic auth if you wanted, or just accept anything + countrycode = request.match_info.get('country', "us") + body = { + "code": "0000", + "data": { + "accessToken": "tempaccesstoken", #Random chars 32 length + "country": countrycode, + "email": "null@null.com", + "uid": "fuid_1", #Date(14)_RandomChars(32) + "username": "fusername_1" #Random chars 8 + }, + "msg": "操作成功", + "time": bumper.current_milli_time() + } + + return web.json_response(body) + + async def handle_logout(self, request): + body = {"code": "0000","data": None,"msg": "操作成功", "time": bumper.current_milli_time()} + + return web.json_response(body) + + + + + async def handle_getAuthCode(self, request): + #Could implement basic auth if you wanted, or just accept anything + countrycode = request.match_info.get('country', "us") + body = { + "code": "0000", + "data": { + "authCode": "{}_tempauthcode".format(countrycode), #countrycode_randomchars(32) + "ecovacsUid": "fuid_1" #Date(14)_RandomChars(32) + }, + "msg": "操作成功", + "time": bumper.current_milli_time() + } + + return web.json_response(body) + + async def handle_checkVersion(self, request): + body = { + "code": "0000", + "data": { + "c": None, + "img": None, + "r": 0, + "t": None, + "u": None, + "ut": 0, + "v": None + }, + "msg": "操作成功", + "time": bumper.current_milli_time() + } + + return web.json_response(body) + + async def handle_checkAgreement(self, request): + body = { + "code": "0000", + "data": [], + "msg": "操作成功", + "time": bumper.current_milli_time() + + } + + return web.json_response(body) + + async def handle_homePageAlert(self, request): + nextAlert = bumper.get_milli_time((datetime.now() + timedelta(hours=12)).timestamp()) + + body = { + "code": "0000", + "data": { + "clickSchemeUrl": None, + "clickWebUrl": None, + "hasCampaign": "N", + "imageUrl": None, + "nextAlertTime": nextAlert, + "serverTime": bumper.current_milli_time() + }, + "msg": "操作成功", + "time": bumper.current_milli_time() + } + + return web.json_response(body) + + async def handle_getProductIotMap(self, request): + #json_body = json.loads(await request.text()) + body = {"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]} + return web.json_response(body) + + async def handle_usersapi(self, request): + #Could implement basic auth if you wanted, or just accept anything + json_body = json.loads(await request.text()) + todo = json_body['todo'] + if todo == 'FindBest': + service = json_body['service'] + if service == 'EcoMsgNew': + body = {"result":"ok","ip":socket.gethostbyname(socket.gethostname()),"port":5223} + elif service == 'EcoUpdate': + body = {"result":"ok","ip":"47.88.66.164","port":8005} + elif todo == 'loginByItToken': + body = { + "resource": json_body["resource"], + "result": "ok", + "todo": "result", + "token": json_body["token"], #RandomChar(32) + "userId": json_body["userId"] #RandomChar(16) + } + elif todo == 'GetDeviceList': + active_bots = self.bumper_clients.get() + body = { + "devices": active_bots, + "result": "ok", + "todo": "result" + } + + return web.json_response(body) + + async def handle_devmanager(self, request): + json_body = json.loads(await request.text()) + randomid = ''.join(random.sample(string.ascii_letters,6)) + retcmd = await self.helperbot.send_command(json_body, randomid) + body = retcmd + + return web.json_response(body) + def disconnect(self): - logging.info('ConfServer: shutting down...') - self.server.disconnect() + logging.info('ConfServer: shutting down...') if(self.run_async): self.server.join() - logging.info('ConfServer: bye') + else: + self.server.disconnect() + logging.info('ConfServer: bye') + \ No newline at end of file diff --git a/bumper/mqttserver.py b/bumper/mqttserver.py index 3a1f6d1..8fe32c4 100644 --- a/bumper/mqttserver.py +++ b/bumper/mqttserver.py @@ -16,6 +16,7 @@ from paho.mqtt import publish as MQTTPublish from paho.mqtt import subscribe as MQTTSubscribe import bumper import json +from datetime import datetime, timedelta class BumperMQTTPlugin: @@ -27,6 +28,8 @@ class BumperMQTTPlugin: self.context.logger.warning("'bots' section not found in context configuration") logging.debug('Bumper Plugin Initialized') + + async def on_broker_client_connected(self, client_id): logging.debug('Bumper Connection: %s connected' % client_id) connected_bots = self.bots['connected_bots'].get() @@ -56,20 +59,21 @@ class BumperMQTTPlugin: class MQTTHelperBot(ClientMQTT): - def __init__(self, address, run_async=False, bumper_clients=contextvars.ContextVar): - ClientMQTT.__init__(self) + ClientMQTT.__init__(self) + self.address = address self._client_id = "helper1@bumper/helper1" + self.command_responses = contextvars.ContextVar('command_responses', default=[]) try: if run_async: hloop = asyncio.new_event_loop() logging.debug("Starting MQTT HelperBot Thread: 1") - mserver = Thread(name="MQTTHelperBot_Thread",target=self.run_helperbot, args=(hloop,)) - mserver.setDaemon(True) - mserver.start() + helperbot = Thread(name="MQTTHelperBot_Thread",target=self.run_helperbot, args=(hloop,)) + helperbot.setDaemon(True) + helperbot.start() else: self.run_helperbot() @@ -79,9 +83,10 @@ class MQTTHelperBot(ClientMQTT): pass def run_helperbot(self, loop): - formatter = "[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s" - logging.basicConfig(level=logging.INFO, format=formatter) + #formatter = "[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s" + #logging.basicConfig(level=logging.INFO, format=formatter) asyncio.set_event_loop(loop) + loop.run_until_complete(self.start_helper_bot()) loop.run_forever() @@ -118,15 +123,23 @@ class MQTTHelperBot(ClientMQTT): def get_msg(self, client, userdata, message): logging.debug("HelperBot MQTT Received Message on Topic: {} - Message: {}".format(message.topic, str(message.payload.decode("utf-8")))) logging.debug(str(message.payload.decode("utf-8"))) - cresp = self.command_responses.get() - #str(message.payload.decode("utf-8") - cresp.append({"topic": message.topic,"payload":str(message.payload.decode("utf-8"))}) + cresp = self.command_responses.get() + + #Cleanup "expired messages" > 60 seconds from time + for msg in cresp: + expire_time = (datetime.fromtimestamp(msg['time']) + timedelta(seconds=10)).timestamp() + if time.time() > expire_time: + logging.debug("Pruning Message Time: {}, MsgTime: {}, MsgTime+60: {}".format(time.time(), msg['time'], expire_time)) + cresp.remove(msg) + + cresp.append({"time": time.time() ,"topic": message.topic,"payload":str(message.payload.decode("utf-8"))}) self.command_responses.set(cresp) + logging.debug("MQTT Command Response List Count: %s" %len(cresp)) async def wait_for_resp(self, requestid): - t_end = time.time() + 10 + t_end = (datetime.now() + timedelta(seconds=10)).timestamp() while time.time() < t_end: - await asyncio.sleep(0.2) + await asyncio.sleep(0.3) responses = self.command_responses.get() if len(responses) > 0: for msg in responses: @@ -150,13 +163,12 @@ class MQTTHelperBot(ClientMQTT): return { "id": requestid, "errno": "timeout", "ret": "fail" } - def send_command(self, cmdjson, requestid): + async def send_command(self, cmdjson, requestid): ttopic = "iot/p2p/{}/helper1/bumper/helper1/{}/{}/{}/q/{}/{}".format(cmdjson["cmdName"], cmdjson["toId"], cmdjson["toType"], cmdjson["toRes"], requestid, cmdjson["payloadType"]) self.publish(ttopic, str(cmdjson["payload"])) - loop = asyncio.new_event_loop() - resp = loop.run_until_complete(self.wait_for_resp(requestid)) + resp = await self.wait_for_resp(requestid) logging.debug(resp) return resp @@ -169,14 +181,22 @@ class MQTTServer(): async def broker_coro(self): broker = hbmqtt.broker.Broker(config=self.default_config) - for plugin in broker.plugins_manager.plugins: - if plugin.name == 'broker_sys': - broker.plugins_manager.plugins.remove(plugin) - if plugin.name == 'packet_logger_plugin': - broker.plugins_manager.plugins.remove(plugin) + logging.debug(broker.plugins_manager.plugins) await broker.start() + logging.debug("Removing Plugin: broker_sys") + broker.plugins_manager.plugins.remove(broker.plugins_manager.get_plugin('broker_sys')) + + logging.debug("Removing Plugin: topic_taboo") + broker.plugins_manager.plugins.remove(broker.plugins_manager.get_plugin('topic_taboo')) + + logging.debug("Removing Plugin: packet_logger_plugin") + broker.plugins_manager.plugins.remove(broker.plugins_manager.get_plugin('packet_logger_plugin')) + + logging.debug("Started Broker and Removed Plugins") + + async def active_bot_listing(self): while True: await asyncio.sleep(5) @@ -224,9 +244,9 @@ class MQTTServer(): if run_async: sloop = asyncio.new_event_loop() logging.debug("Starting MQTTServer Thread: 1") - mserver = Thread(name="MQTTServer_Thread",target=self.run_server, args=(sloop,)) - mserver.setDaemon(True) - mserver.start() + mqttserver = Thread(name="MQTTServer_Thread",target=self.run_server, args=(sloop,)) + mqttserver.setDaemon(True) + mqttserver.start() else: self.run_server() @@ -237,8 +257,8 @@ class MQTTServer(): def run_server(self, loop): - formatter = "[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s" - logging.basicConfig(level=logging.INFO, format=formatter) + #formatter = "[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s" + #logging.basicConfig(level=logging.INFO, format=formatter) asyncio.set_event_loop(loop) loop.run_until_complete(self.broker_coro()) #loop.run_until_complete(self.active_bot_listing()) diff --git a/bumper/xmppserver.py b/bumper/xmppserver.py index 6be00d8..b4d7d56 100644 --- a/bumper/xmppserver.py +++ b/bumper/xmppserver.py @@ -27,6 +27,7 @@ class XMPPServer(): client.disconnect() thread_id = uuid.uuid4() client = Client(thread_id, connection, client_address) + client.start() self.clients.append(client) self.socket.close() @@ -61,6 +62,7 @@ class Client(threading.Thread): def __init__(self, thread_id, connection, client_address): threading.Thread.__init__(self) self.id = thread_id + self.name = "XMPP Thread {}".format(self.id) self.type = self.UNKNOWN self.state = self.IDLE self.connection = connection From 99508f25dcfb29b57129f17723d11f10aa03c1d2 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Fri, 1 Feb 2019 18:05:26 -0500 Subject: [PATCH 07/34] Fix logging Fix logging --- bumper.py | 6 +++--- bumper/confserver.py | 15 ++++----------- bumper/mqttserver.py | 40 +++++++++++++++++++--------------------- 3 files changed, 26 insertions(+), 35 deletions(-) diff --git a/bumper.py b/bumper.py index 6c086c9..ae4b6a8 100644 --- a/bumper.py +++ b/bumper.py @@ -9,11 +9,11 @@ import time args = sys.argv if len(args) > 0: if '--debug' in args: - logging.basicConfig(level=logging.DEBUG, - format='%(asctime)s %(levelname)-8s %(message)s') + logging.basicConfig(level=logging.DEBUG, + format="[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s") else: logging.basicConfig(level=logging.INFO, - format='%(asctime)s %(levelname)-8s %(message)s') + format="[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s") #conf_address = (socket.gethostbyname(socket.gethostname()), 443) conf_address = ("0.0.0.0", 443) diff --git a/bumper/confserver.py b/bumper/confserver.py index 956af3f..088b902 100644 --- a/bumper/confserver.py +++ b/bumper/confserver.py @@ -49,9 +49,7 @@ class ConfServer(): loop.run_forever() - async def start_server(self): - #formatter = "[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s" - #logging.basicConfig(level=logging.INFO, format=formatter) + async def start_server(self): app = web.Application() app.add_routes([ @@ -68,7 +66,7 @@ class ConfServer(): ]) - runner = web.AppRunner(app, access_log=None) + runner = web.AppRunner(app, access_log=None) #access_log=None so the output isn't nuts await runner.setup() if self.usessl: @@ -105,11 +103,7 @@ class ConfServer(): return web.json_response(body) - - - - async def handle_getAuthCode(self, request): - #Could implement basic auth if you wanted, or just accept anything + async def handle_getAuthCode(self, request): countrycode = request.match_info.get('country', "us") body = { "code": "0000", @@ -176,8 +170,7 @@ class ConfServer(): body = {"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]} return web.json_response(body) - async def handle_usersapi(self, request): - #Could implement basic auth if you wanted, or just accept anything + async def handle_usersapi(self, request): json_body = json.loads(await request.text()) todo = json_body['todo'] if todo == 'FindBest': diff --git a/bumper/mqttserver.py b/bumper/mqttserver.py index 8fe32c4..7d13663 100644 --- a/bumper/mqttserver.py +++ b/bumper/mqttserver.py @@ -3,9 +3,8 @@ import logging import asyncio import os -from hbmqtt.broker import Broker -from hbmqtt.client import MQTTClient import hbmqtt +from hbmqtt.broker import Broker import pkg_resources import contextvars import time @@ -26,9 +25,7 @@ class BumperMQTTPlugin: self.bots = self.context.config['bots'] except KeyError: self.context.logger.warning("'bots' section not found in context configuration") - logging.debug('Bumper Plugin Initialized') - - + async def on_broker_client_connected(self, client_id): logging.debug('Bumper Connection: %s connected' % client_id) @@ -55,7 +52,16 @@ class BumperMQTTPlugin: async def on_broker_client_disconnected(self, client_id): logging.debug('Bumper Connection: %s disconnected' % client_id) - #To do handle removing bot + connected_bots = self.bots['connected_bots'].get() + didsplit = str(client_id).split("@") + #If the did is in the list, remove it + for bot in connected_bots: + if didsplit[0] == bot['did']: + logging.debug("Removing bot from list: {}".format(bot)) + connected_bots.remove(bot) + self.bots['connected_bots'].set(connected_bots) + + logging.debug('Connected Bots: %s' %self.bots['connected_bots'].get()) class MQTTHelperBot(ClientMQTT): @@ -83,8 +89,6 @@ class MQTTHelperBot(ClientMQTT): pass def run_helperbot(self, loop): - #formatter = "[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s" - #logging.basicConfig(level=logging.INFO, format=formatter) asyncio.set_event_loop(loop) loop.run_until_complete(self.start_helper_bot()) @@ -121,15 +125,14 @@ class MQTTHelperBot(ClientMQTT): def get_msg(self, client, userdata, message): - logging.debug("HelperBot MQTT Received Message on Topic: {} - Message: {}".format(message.topic, str(message.payload.decode("utf-8")))) - logging.debug(str(message.payload.decode("utf-8"))) + #logging.debug("HelperBot MQTT Received Message on Topic: {} - Message: {}".format(message.topic, str(message.payload.decode("utf-8")))) cresp = self.command_responses.get() #Cleanup "expired messages" > 60 seconds from time for msg in cresp: expire_time = (datetime.fromtimestamp(msg['time']) + timedelta(seconds=10)).timestamp() if time.time() > expire_time: - logging.debug("Pruning Message Time: {}, MsgTime: {}, MsgTime+60: {}".format(time.time(), msg['time'], expire_time)) + #logging.debug("Pruning Message Time: {}, MsgTime: {}, MsgTime+60: {}".format(time.time(), msg['time'], expire_time)) cresp.remove(msg) cresp.append({"time": time.time() ,"topic": message.topic,"payload":str(message.payload.decode("utf-8"))}) @@ -145,12 +148,11 @@ class MQTTHelperBot(ClientMQTT): for msg in responses: topic = str(msg['topic']).split("/") if (topic[6] == "helper1" and topic[10] == requestid): - logging.debug('Vac Responses MQTT: Topic: %s Payload: %s' % (msg['topic'], msg['payload'])) + logging.debug('VacBot MQTT Response: Topic: %s Payload: %s' % (msg['topic'], msg['payload'])) if topic[11] == "j": resppayload = json.loads(msg['payload']) else: resppayload = str(msg['payload']) - logging.debug("Resp Payload: %s" % resppayload) resp = { "id": requestid, "ret": "ok", @@ -169,20 +171,17 @@ class MQTTHelperBot(ClientMQTT): self.publish(ttopic, str(cmdjson["payload"])) resp = await self.wait_for_resp(requestid) - - logging.debug(resp) + return resp class MQTTServer(): - exit_flag = False default_config = {} bumper_clients = [] async def broker_coro(self): - broker = hbmqtt.broker.Broker(config=self.default_config) - - logging.debug(broker.plugins_manager.plugins) + broker = hbmqtt.broker.Broker(config=self.default_config) + await broker.start() logging.debug("Removing Plugin: broker_sys") @@ -203,6 +202,7 @@ class MQTTServer(): logging.debug('Connected bots: %s' % self.bumper_clients.get()) def __init__(self, address, run_async=False, bumper_clients=contextvars.ContextVar): + #The below adds a plugin to the hbmqtt.broker.plugins without having to futz with setup.py distribution = pkg_resources.Distribution("hbmqtt.broker.plugins") bumper_plugin = pkg_resources.EntryPoint.parse('bumper = bumper.mqttserver:BumperMQTTPlugin', dist=distribution) @@ -257,8 +257,6 @@ class MQTTServer(): def run_server(self, loop): - #formatter = "[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s" - #logging.basicConfig(level=logging.INFO, format=formatter) asyncio.set_event_loop(loop) loop.run_until_complete(self.broker_coro()) #loop.run_until_complete(self.active_bot_listing()) From d9bf97ce57dd63b59b7c6c9ab8d2c953c225cee2 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Sat, 2 Feb 2019 04:57:47 -0500 Subject: [PATCH 08/34] Remove paho and use hbmqtt for helper Remove paho and use hbmqtt for helper Fix sleep bug --- Pipfile | 1 - bumper/__init__.py | 1 - bumper/confserver.py | 14 +-- bumper/mqttserver.py | 218 +++++++++++++++++++------------------------ bumper/xmppserver.py | 1 + 5 files changed, 105 insertions(+), 130 deletions(-) diff --git a/Pipfile b/Pipfile index db09990..d5d8bc7 100644 --- a/Pipfile +++ b/Pipfile @@ -5,7 +5,6 @@ name = "pypi" [packages] hbmqtt = "*" -paho-mqtt = "*" aiohttp = "*" [dev-packages] diff --git a/bumper/__init__.py b/bumper/__init__.py index 3195f7f..fd9349c 100644 --- a/bumper/__init__.py +++ b/bumper/__init__.py @@ -9,7 +9,6 @@ import contextvars import time bumper_clients_var = contextvars.ContextVar('bumper_clients', default=[]) -current_milli_time = lambda: int(round(time.time() * 1000)) def get_milli_time(timetoconvert): return int(round(timetoconvert * 1000)) diff --git a/bumper/confserver.py b/bumper/confserver.py index 088b902..398f9b2 100644 --- a/bumper/confserver.py +++ b/bumper/confserver.py @@ -93,13 +93,13 @@ class ConfServer(): "username": "fusername_1" #Random chars 8 }, "msg": "操作成功", - "time": bumper.current_milli_time() + "time": bumper.get_milli_time(time.time()) } return web.json_response(body) async def handle_logout(self, request): - body = {"code": "0000","data": None,"msg": "操作成功", "time": bumper.current_milli_time()} + body = {"code": "0000","data": None,"msg": "操作成功", "time": bumper.get_milli_time(time.time())} return web.json_response(body) @@ -112,7 +112,7 @@ class ConfServer(): "ecovacsUid": "fuid_1" #Date(14)_RandomChars(32) }, "msg": "操作成功", - "time": bumper.current_milli_time() + "time": bumper.get_milli_time(time.time()) } return web.json_response(body) @@ -130,7 +130,7 @@ class ConfServer(): "v": None }, "msg": "操作成功", - "time": bumper.current_milli_time() + "time": bumper.get_milli_time(time.time()) } return web.json_response(body) @@ -140,7 +140,7 @@ class ConfServer(): "code": "0000", "data": [], "msg": "操作成功", - "time": bumper.current_milli_time() + "time": bumper.get_milli_time(time.time()) } @@ -157,10 +157,10 @@ class ConfServer(): "hasCampaign": "N", "imageUrl": None, "nextAlertTime": nextAlert, - "serverTime": bumper.current_milli_time() + "serverTime": bumper.get_milli_time(time.time()) }, "msg": "操作成功", - "time": bumper.current_milli_time() + "time": bumper.get_milli_time(time.time()) } return web.json_response(body) diff --git a/bumper/mqttserver.py b/bumper/mqttserver.py index 7d13663..3db6866 100644 --- a/bumper/mqttserver.py +++ b/bumper/mqttserver.py @@ -5,74 +5,26 @@ import asyncio import os import hbmqtt from hbmqtt.broker import Broker +from hbmqtt.client import MQTTClient +from hbmqtt.mqtt.constants import QOS_0, QOS_1, QOS_2 import pkg_resources import contextvars import time from threading import Thread import ssl -from paho.mqtt.client import Client as ClientMQTT -from paho.mqtt import publish as MQTTPublish -from paho.mqtt import subscribe as MQTTSubscribe import bumper import json from datetime import datetime, timedelta -class BumperMQTTPlugin: - def __init__(self, context): - self.context = context - try: - self.bots = self.context.config['bots'] - except KeyError: - self.context.logger.warning("'bots' section not found in context configuration") - - - async def on_broker_client_connected(self, client_id): - logging.debug('Bumper Connection: %s connected' % client_id) - connected_bots = self.bots['connected_bots'].get() - didsplit = str(client_id).split("@") - #If this isn't a fake user (fuid) then add as a bot - if not (str(didsplit[0]).startswith("fuid") or str(didsplit[0]).startswith("helper")): - tmpbotdetail = str(didsplit[1]).split("/") - newbot = bumper.VacBotDevice() - newbot.did = didsplit[0] - newbot.vac_bot_device_class = tmpbotdetail[0] - newbot.resource = tmpbotdetail[1] - botactive = False - for bot in connected_bots: - if bot['did'] == newbot.did: - botactive = True - - if botactive == False: - connected_bots.append(newbot.asdict()) - - self.bots['connected_bots'].set(connected_bots) - - logging.debug('Connected Bots: %s' %self.bots['connected_bots'].get()) - - async def on_broker_client_disconnected(self, client_id): - logging.debug('Bumper Connection: %s disconnected' % client_id) - connected_bots = self.bots['connected_bots'].get() - didsplit = str(client_id).split("@") - #If the did is in the list, remove it - for bot in connected_bots: - if didsplit[0] == bot['did']: - logging.debug("Removing bot from list: {}".format(bot)) - connected_bots.remove(bot) - self.bots['connected_bots'].set(connected_bots) - - logging.debug('Connected Bots: %s' %self.bots['connected_bots'].get()) - -class MQTTHelperBot(ClientMQTT): - +class MQTTHelperBot(): + Client = MQTTClient() def __init__(self, address, run_async=False, bumper_clients=contextvars.ContextVar): - ClientMQTT.__init__(self) - - self.address = address - self._client_id = "helper1@bumper/helper1" + self.address = address + self.client_id = "helper1@bumper/helper1" self.command_responses = contextvars.ContextVar('command_responses', default=[]) - + try: if run_async: hloop = asyncio.new_event_loop() @@ -88,61 +40,52 @@ class MQTTHelperBot(ClientMQTT): logging.exception("Exception") pass - def run_helperbot(self, loop): - asyncio.set_event_loop(loop) + def run_helperbot(self, loop): - loop.run_until_complete(self.start_helper_bot()) + asyncio.set_event_loop(loop) + self.Client = MQTTClient(client_id=self.client_id, config={'check_hostname':False}) + loop.run_until_complete(self.start_helper_bot()) + loop.run_until_complete(self.get_msg()) loop.run_forever() async def start_helper_bot(self): - #self._on_log = self.on_log #This provides more logging than needed, even for debug - self._on_message = self.get_msg - self._on_connect = self.on_connect - #TODO: This is pretty insecure and accepts any cert, maybe actually check? - ssl_ctx = ssl.create_default_context() - ssl_ctx.check_hostname = False - ssl_ctx.verify_mode = ssl.CERT_NONE - self.tls_set_context(ssl_ctx) - self.tls_insecure_set(True) + try: + await self.Client.connect('mqtts://{}:{}/'.format(self.address[0], self.address[1]), cafile='./certs/CA/cacert.pem') + await self.Client.subscribe([ + ('iot/p2p/+/+/+/+/helper1/bumper/helper1/+/+/+',QOS_0), + ('iot/p2p/+',QOS_0) + ]) + except hbmqtt.client.ClientException as ce: + logging.exception("Client exception: %s" % ce) - self.connect(self.address[0], self.address[1]) - self.loop_start() - - def on_connect(self, client, userdata, flags, rc): - if rc != 0: - logging.error("HelperBot - error connecting with MQTT Return {}".format(rc)) - raise RuntimeError("HelperBot - error connecting with MQTT Return {}".format(rc)) - - else: - logging.debug("HelperBot - Connected with result code "+str(rc)) - logging.debug("HelperBot - Subscribing to all") + async def get_msg(self): + try: + while True: + message = await self.Client.deliver_message() + + #logging.debug("HelperBot MQTT Received Message on Topic: {} - Message: {}".format(message.topic, str(message.payload.decode("utf-8")))) + cresp = self.command_responses.get() + + #Cleanup "expired messages" > 60 seconds from time + for msg in cresp: + expire_time = (datetime.fromtimestamp(msg['time']) + timedelta(seconds=10)).timestamp() + if time.time() > expire_time: + #logging.debug("Pruning Message Time: {}, MsgTime: {}, MsgTime+60: {}".format(time.time(), msg['time'], expire_time)) + cresp.remove(msg) - - self.subscribe('iot/p2p/+/+/+/+/helper1/bumper/helper1/+/+/+', qos=0) - self.subscribe('iot/p2p/+', qos=0) - - - def get_msg(self, client, userdata, message): - #logging.debug("HelperBot MQTT Received Message on Topic: {} - Message: {}".format(message.topic, str(message.payload.decode("utf-8")))) - cresp = self.command_responses.get() - - #Cleanup "expired messages" > 60 seconds from time - for msg in cresp: - expire_time = (datetime.fromtimestamp(msg['time']) + timedelta(seconds=10)).timestamp() - if time.time() > expire_time: - #logging.debug("Pruning Message Time: {}, MsgTime: {}, MsgTime+60: {}".format(time.time(), msg['time'], expire_time)) - cresp.remove(msg) - - cresp.append({"time": time.time() ,"topic": message.topic,"payload":str(message.payload.decode("utf-8"))}) - self.command_responses.set(cresp) - logging.debug("MQTT Command Response List Count: %s" %len(cresp)) + cresp.append({"time": time.time() ,"topic": message.topic,"payload":str(message.data.decode("utf-8"))}) + self.command_responses.set(cresp) + logging.debug("MQTT Command Response List Count: %s" %len(cresp)) + except hbmqtt.client.ClientException as ce: + logging.error("Client exception: %s" % ce) + async def wait_for_resp(self, requestid): t_end = (datetime.now() + timedelta(seconds=10)).timestamp() while time.time() < t_end: - await asyncio.sleep(0.3) + await asyncio.sleep(0.1) responses = self.command_responses.get() if len(responses) > 0: for msg in responses: @@ -166,13 +109,17 @@ class MQTTHelperBot(ClientMQTT): return { "id": requestid, "errno": "timeout", "ret": "fail" } async def send_command(self, cmdjson, requestid): + ttopic = "iot/p2p/{}/helper1/bumper/helper1/{}/{}/{}/q/{}/{}".format(cmdjson["cmdName"], - cmdjson["toId"], cmdjson["toType"], cmdjson["toRes"], requestid, cmdjson["payloadType"]) - self.publish(ttopic, str(cmdjson["payload"])) - + cmdjson["toId"], cmdjson["toType"], cmdjson["toRes"], requestid, cmdjson["payloadType"]) + try: + await self.Client.publish(ttopic, str(cmdjson["payload"]).encode(),QOS_0) + except: + logging.exception("Exception at send_command") + resp = await self.wait_for_resp(requestid) - - return resp + + return resp class MQTTServer(): @@ -180,21 +127,8 @@ class MQTTServer(): bumper_clients = [] async def broker_coro(self): - broker = hbmqtt.broker.Broker(config=self.default_config) - - await broker.start() - - logging.debug("Removing Plugin: broker_sys") - broker.plugins_manager.plugins.remove(broker.plugins_manager.get_plugin('broker_sys')) - - logging.debug("Removing Plugin: topic_taboo") - broker.plugins_manager.plugins.remove(broker.plugins_manager.get_plugin('topic_taboo')) - - logging.debug("Removing Plugin: packet_logger_plugin") - broker.plugins_manager.plugins.remove(broker.plugins_manager.get_plugin('packet_logger_plugin')) - - logging.debug("Started Broker and Removed Plugins") - + broker = hbmqtt.broker.Broker(config=self.default_config) + await broker.start() async def active_bot_listing(self): while True: @@ -205,11 +139,9 @@ class MQTTServer(): #The below adds a plugin to the hbmqtt.broker.plugins without having to futz with setup.py distribution = pkg_resources.Distribution("hbmqtt.broker.plugins") - bumper_plugin = pkg_resources.EntryPoint.parse('bumper = bumper.mqttserver:BumperMQTTPlugin', dist=distribution) + bumper_plugin = pkg_resources.EntryPoint.parse('bumper = bumper.mqttserver:BumperMQTTServer_Plugin', dist=distribution) distribution._ep_map = {"hbmqtt.broker.plugins": {"bumper": bumper_plugin}} pkg_resources.working_set.add(distribution) - #for entry_point in pkg_resources.iter_entry_points("hbmqtt.broker.plugins"): - # print(entry_point) self.bumper_clients = bumper_clients try: @@ -262,3 +194,47 @@ class MQTTServer(): #loop.run_until_complete(self.active_bot_listing()) loop.run_forever() +class BumperMQTTServer_Plugin: + def __init__(self, context): + self.context = context + try: + self.bots = self.context.config['bots'] + except KeyError: + self.context.logger.warning("'bots' section not found in context configuration") + + + async def on_broker_client_connected(self, client_id): + logging.debug('Bumper Connection: %s connected' % client_id) + connected_bots = self.bots['connected_bots'].get() + didsplit = str(client_id).split("@") + #If this isn't a fake user (fuid) then add as a bot + if not (str(didsplit[0]).startswith("fuid") or str(didsplit[0]).startswith("helper")): + tmpbotdetail = str(didsplit[1]).split("/") + newbot = bumper.VacBotDevice() + newbot.did = didsplit[0] + newbot.vac_bot_device_class = tmpbotdetail[0] + newbot.resource = tmpbotdetail[1] + botactive = False + for bot in connected_bots: + if bot['did'] == newbot.did: + botactive = True + + if botactive == False: + connected_bots.append(newbot.asdict()) + + self.bots['connected_bots'].set(connected_bots) + + logging.debug('Connected Bots: %s' %self.bots['connected_bots'].get()) + + async def on_broker_client_disconnected(self, client_id): + logging.debug('Bumper Connection: %s disconnected' % client_id) + connected_bots = self.bots['connected_bots'].get() + didsplit = str(client_id).split("@") + #If the did is in the list, remove it + for bot in connected_bots: + if didsplit[0] == bot['did']: + logging.debug("Removing bot from list: {}".format(bot)) + connected_bots.remove(bot) + self.bots['connected_bots'].set(connected_bots) + + logging.debug('Connected Bots: %s' %self.bots['connected_bots'].get()) \ No newline at end of file diff --git a/bumper/xmppserver.py b/bumper/xmppserver.py index b4d7d56..e958516 100644 --- a/bumper/xmppserver.py +++ b/bumper/xmppserver.py @@ -127,6 +127,7 @@ class Client(threading.Thread): logging.info('client connected: {}'.format(self.address)) self._set_state('CONNECT') while True: + time.sleep(0.2) if not self.connection._closed: data = self.connection.recv(4096) if data: From e0c9382156e5152cffc04b38b4303adfd77a92d7 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Sat, 2 Feb 2019 11:35:48 -0500 Subject: [PATCH 09/34] Remove old certs Remove old certs --- certs/cert.pem.bak | 33 ------------------------------ certs/key.pem.bak | 51 ---------------------------------------------- 2 files changed, 84 deletions(-) delete mode 100644 certs/cert.pem.bak delete mode 100644 certs/key.pem.bak diff --git a/certs/cert.pem.bak b/certs/cert.pem.bak deleted file mode 100644 index 87925f8..0000000 --- a/certs/cert.pem.bak +++ /dev/null @@ -1,33 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFtTCCA52gAwIBAgIJAKLG5udzkR+zMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV -BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX -aWRnaXRzIFB0eSBMdGQwHhcNMTcxMjA1MTkxNTQwWhcNMTgxMjA1MTkxNTQwWjBF -MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 -ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC -CgKCAgEAu1z0Iw7KLJZD8CBrZWm9Jp7eqIAqdoTn3GWmNmzgIDnzmxZb70War74d -7aWDVtEVtOZQ8ShW15zRanr6hQkHdM5Li1KTMYW3P7TOmzJZkESNEZviOyvlfRPG -oLLbPIp+A7W8Zz3ST0cDzyy6N7LRAA0OywRgPhrxrN637Fx1+SFwqKQg0IUOuX3v -veZ1oAZSBUDzpDPUaak7jXugB11wt6XTEekwOcrH7WEDoFH87thtJyCM7IidW0bM -rkqxeiM18qMzn8cGqnm2nY+VHgK0t0/QZ/cftPVc2IHZLr/+eTGaJmPv7gL+7v/l -7fgk3RMTn0GrdTO+Vvnu1qNK7r+//KMgGOo6zwEQAI9Ml6y1LRuEXrymgsYY9qCE -ZtBV0FoZaVYAuGG4cvCDLLPgM2OivIcWgG+77MqMH/RkWg+BrUesn7ISEpF+7Q7+ -jc6pFYr4y5fc2jJ9/c5Mxu6sv0Tc8N/c9WpcXsJ6Y1JrBbDcdGSwmTZ1JVyiog9O -6bU/16QTuU3w2oH9qjnc907tZuB66P53WJ2iHPvB8yF5V93FvtbPYsHpO7noURSI -LqvKoNy31ndv4kao2AIzrJuhBBkSorBEcoFerRRBJMHa85R5Tb6iuGg6RFv0BB8K -gv6mG/7oqmR8QDnRRd54U2pEsIkYmgoIU4/8L91r9Vd1eiEDEnsCAwEAAaOBpzCB -pDAdBgNVHQ4EFgQUqA/GBsZAepFIcbgw6gK/HTUWolswdQYDVR0jBG4wbIAUqA/G -BsZAepFIcbgw6gK/HTUWoluhSaRHMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpT -b21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGSCCQCi -xubnc5EfszAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4ICAQB7kHEHKNDQ -TcvwY9z3RscpHyWwjrGjUdjk8AkM9cTm/sT8G7JU/7+KZueu4elNOc4hir/+DMlO -/AN6IXRNrfPO8IVG6hKnK9K3wvceJzg6mT8F4peg4vMNdIsrZyQaPhWArUF7LG/Q -HF4Yk8jnw3NKIG5fnlagAGFDdDGSdDK/IQN+JA6sHJVQRrBLpksRUCWFY7QJGYVS -DmEAYr6Y6emftbflUjW6iC/E57lC5F7bQyoIEIBBHKjMaNTdM8TzOUZSOGLwAuVD -C1iAoZn7VWeGa4h/xwx0yio0yeI6gRh3fINBKoCNv6pLUm6sgrW8oqGMDJiH0T3H -sc8xC3bK2DPO4sIyisL3HUPcsSFB/hf0i8BOQrihrpZEn/fx4lTRyQYSKT5NVSl4 -v/9hmDjWIXh9EInGfP8DdWp4Og8GiXcV9rUqvRBo5aBwqK/aTNRCk6ah5N958yFu -wHlrbQmPnw1AqJSG1oG+4dcXnrcgd/xGMAarTbgzqsd8icafg8v2x2pG5ZexE3uN -0METK9hvCrjN8/Rvo2ViTDa8WF4CXMuVFG4FdbeRrYPZl1j1jBYDMtKkVJJCsVQd -chYbqKgNDmMcbfpksBtF3XpYTtVNAKA9g5mrAhEJS88uy63PStLoBQNXv9y8ksql -VVCTFNGy5JRyvOeSRYSSdXjKbfj5Vgh1HQ== ------END CERTIFICATE----- diff --git a/certs/key.pem.bak b/certs/key.pem.bak deleted file mode 100644 index 852040d..0000000 --- a/certs/key.pem.bak +++ /dev/null @@ -1,51 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -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 ------END RSA PRIVATE KEY----- From f7d91aa650f7a24f4312189495311bd55d996c03 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Sun, 3 Feb 2019 16:16:29 -0500 Subject: [PATCH 10/34] Split bots and clients, Fix XMPP threads not exiting Split bots and clients, Fix XMPP threads not exiting --- bumper.py | 8 +-- bumper/__init__.py | 14 +++++ bumper/confserver.py | 34 +++++++----- bumper/mqttserver.py | 74 ++++++++++++++++++------- bumper/xmppserver.py | 125 +++++++++++++++++++++++++++++-------------- 5 files changed, 177 insertions(+), 78 deletions(-) diff --git a/bumper.py b/bumper.py index ae4b6a8..1a98928 100644 --- a/bumper.py +++ b/bumper.py @@ -24,18 +24,18 @@ mqtt_address = ("0.0.0.0", 8883) # A default bot could be set here to automatically add it as available # dbot = bumper.VacBotDevice("did", "class", "resource", "name","nick" ) -# bclient = bumper.bumper_clients_var +# bclient = bumper.bumper_bots_var # bclienttemp = bclient.get() # bclienttemp.append(dbot.asdict()) # bclient.set(bclienttemp) # start mqtt server (async) -mqtt_server = bumper.MQTTServer(mqtt_address, run_async=True,bumper_clients=bumper.bumper_clients_var) +mqtt_server = bumper.MQTTServer(mqtt_address, run_async=True,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var) time.sleep(1.5) #Wait for broker startup # start mqtt server (async) -mqtt_helperbot = bumper.MQTTHelperBot(mqtt_address, run_async=True,bumper_clients=bumper.bumper_clients_var) +mqtt_helperbot = bumper.MQTTHelperBot(mqtt_address, run_async=True,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var) # start conf server (async) -conf_server = bumper.ConfServer(conf_address, usessl=True, run_async=True, bumper_clients=bumper.bumper_clients_var, helperbot=mqtt_helperbot) +conf_server = bumper.ConfServer(conf_address, usessl=True, run_async=True,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var, helperbot=mqtt_helperbot) # start xmpp server (sync) xmpp_server = bumper.XMPPServer(xmpp_address) diff --git a/bumper/__init__.py b/bumper/__init__.py index fd9349c..bfcdb9f 100644 --- a/bumper/__init__.py +++ b/bumper/__init__.py @@ -9,6 +9,10 @@ import contextvars import time bumper_clients_var = contextvars.ContextVar('bumper_clients', default=[]) +bumper_bots_var = contextvars.ContextVar('bumper_bots', default=[]) +ca_cert = './certs/CA/cacert.pem' +server_cert = './certs/cert.pem' +server_key = './certs/key.pem' def get_milli_time(timetoconvert): return int(round(timetoconvert * 1000)) @@ -26,3 +30,13 @@ class VacBotDevice(object): def asdict(self): return {"class": self.vac_bot_device_class, "company": self.company, "did": self.did, "name": self.name, "nick": self.nick, "resource": self.resource} + +class VacBotUser(object): + def __init__(self,userid="",realm="",token=""): + self.userid = userid + self.realm = realm + self.resource = token + + + def asdict(self): + return {"userid": self.userid,"realm": self.realm,"resource": self.resource} diff --git a/bumper/confserver.py b/bumper/confserver.py index 398f9b2..ec6b70a 100644 --- a/bumper/confserver.py +++ b/bumper/confserver.py @@ -13,8 +13,10 @@ from aiohttp import web class ConfServer(): bumper_clients = contextvars.ContextVar + bumper_bots = contextvars.ContextVar - def __init__(self, address, usessl=False, run_async=True, bumper_clients=contextvars.ContextVar, helperbot=None): + def __init__(self, address, usessl=False, run_async=True, bumper_bots=contextvars.ContextVar, bumper_clients=contextvars.ContextVar, helperbot=None): + self.bumper_bots = bumper_bots self.bumper_clients = bumper_clients self.helperbot = helperbot self.usessl = usessl @@ -54,15 +56,16 @@ class ConfServer(): app.add_routes([ web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/login', self.handle_login), - web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/logout', self.handle_login), - web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/getAuthCode', self.handle_getAuthCode), + web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/checkLogin', self.handle_login), + web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/logout', self.handle_logout), + web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/getAuthCode', self.handle_getAuthCode), web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/checkAgreement', self.handle_checkAgreement), - web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/common/getAuthCode', self.handle_checkVersion), + web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/common/checkVersion', self.handle_checkVersion), web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/campaign/homePageAlert', self.handle_homePageAlert), web.post('/api/users/user.do', self.handle_usersapi), web.post('/api/pim/product/getProductIotMap', self.handle_getProductIotMap), - web.post('/api/iot/devmanager.do', self.handle_devmanager) + web.post('/api/iot/devmanager.do', self.handle_devmanager_botcommand) ]) @@ -71,7 +74,7 @@ class ConfServer(): if self.usessl: ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) - ssl_ctx.load_cert_chain('./certs/cert.pem', './certs/key.pem') + ssl_ctx.load_cert_chain(bumper.server_cert,bumper.server_key) site = web.TCPSite(runner, host=self.address[0], port=self.address[1],ssl_context=ssl_ctx) else: @@ -89,8 +92,8 @@ class ConfServer(): "accessToken": "tempaccesstoken", #Random chars 32 length "country": countrycode, "email": "null@null.com", - "uid": "fuid_1", #Date(14)_RandomChars(32) - "username": "fusername_1" #Random chars 8 + "uid": "fuid_{}".format(''.join(random.sample(string.ascii_letters,6))), #Date(14)_RandomChars(32) + "username": "fusername_{}".format(''.join(random.sample(string.ascii_letters,6))) #Random chars 8 }, "msg": "操作成功", "time": bumper.get_milli_time(time.time()) @@ -100,7 +103,8 @@ class ConfServer(): async def handle_logout(self, request): body = {"code": "0000","data": None,"msg": "操作成功", "time": bumper.get_milli_time(time.time())} - + #TODO - when logging out close out any other connections MQTT/XMPP + return web.json_response(body) async def handle_getAuthCode(self, request): @@ -109,13 +113,13 @@ class ConfServer(): "code": "0000", "data": { "authCode": "{}_tempauthcode".format(countrycode), #countrycode_randomchars(32) - "ecovacsUid": "fuid_1" #Date(14)_RandomChars(32) + "ecovacsUid": "fuid_{}".format(''.join(random.sample(string.ascii_letters,6))) #Date(14)_RandomChars(32) }, "msg": "操作成功", "time": bumper.get_milli_time(time.time()) } - return web.json_response(body) + return web.json_response(body) async def handle_checkVersion(self, request): body = { @@ -170,7 +174,9 @@ class ConfServer(): body = {"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]} return web.json_response(body) - async def handle_usersapi(self, request): + async def handle_usersapi(self, request): + body = {} + json_body = json.loads(await request.text()) todo = json_body['todo'] if todo == 'FindBest': @@ -188,7 +194,7 @@ class ConfServer(): "userId": json_body["userId"] #RandomChar(16) } elif todo == 'GetDeviceList': - active_bots = self.bumper_clients.get() + active_bots = self.bumper_bots.get() body = { "devices": active_bots, "result": "ok", @@ -197,7 +203,7 @@ class ConfServer(): return web.json_response(body) - async def handle_devmanager(self, request): + async def handle_devmanager_botcommand(self, request): json_body = json.loads(await request.text()) randomid = ''.join(random.sample(string.ascii_letters,6)) retcmd = await self.helperbot.send_command(json_body, randomid) diff --git a/bumper/mqttserver.py b/bumper/mqttserver.py index 3db6866..83ce588 100644 --- a/bumper/mqttserver.py +++ b/bumper/mqttserver.py @@ -19,7 +19,7 @@ from datetime import datetime, timedelta class MQTTHelperBot(): Client = MQTTClient() - def __init__(self, address, run_async=False, bumper_clients=contextvars.ContextVar): + def __init__(self, address, run_async=False, bumper_bots=contextvars.ContextVar, bumper_clients=contextvars.ContextVar): self.address = address self.client_id = "helper1@bumper/helper1" @@ -51,7 +51,7 @@ class MQTTHelperBot(): async def start_helper_bot(self): try: - await self.Client.connect('mqtts://{}:{}/'.format(self.address[0], self.address[1]), cafile='./certs/CA/cacert.pem') + await self.Client.connect('mqtts://{}:{}/'.format(self.address[0], self.address[1]), cafile=bumper.ca_cert) await self.Client.subscribe([ ('iot/p2p/+/+/+/+/helper1/bumper/helper1/+/+/+',QOS_0), ('iot/p2p/+',QOS_0) @@ -125,6 +125,7 @@ class MQTTHelperBot(): class MQTTServer(): default_config = {} bumper_clients = [] + bumper_bots = [] async def broker_coro(self): broker = hbmqtt.broker.Broker(config=self.default_config) @@ -133,16 +134,16 @@ class MQTTServer(): async def active_bot_listing(self): while True: await asyncio.sleep(5) - logging.debug('Connected bots: %s' % self.bumper_clients.get()) + logging.debug('Connected bots: %s' % self.bumper_bots.get()) - def __init__(self, address, run_async=False, bumper_clients=contextvars.ContextVar): + def __init__(self, address, run_async=False, bumper_bots=contextvars.ContextVar, bumper_clients=contextvars.ContextVar): #The below adds a plugin to the hbmqtt.broker.plugins without having to futz with setup.py distribution = pkg_resources.Distribution("hbmqtt.broker.plugins") bumper_plugin = pkg_resources.EntryPoint.parse('bumper = bumper.mqttserver:BumperMQTTServer_Plugin', dist=distribution) distribution._ep_map = {"hbmqtt.broker.plugins": {"bumper": bumper_plugin}} pkg_resources.working_set.add(distribution) - + self.bumper_bots = bumper_bots self.bumper_clients = bumper_clients try: # Initialize bot server @@ -154,8 +155,8 @@ class MQTTServer(): 'tls1': { 'bind': "{}:{}".format(address[0], address[1]), 'ssl': 'on', - 'certfile': './certs/cert.pem', - 'keyfile': './certs/key.pem', + 'certfile': bumper.server_cert, + 'keyfile': bumper.server_key, }, }, 'sys_interval': 10, @@ -169,8 +170,10 @@ class MQTTServer(): 'topic-check': { 'enabled': False }, - 'bots':{ - 'connected_bots': bumper_clients + 'clients':{ + 'connected_bots': bumper_bots, + 'connected_clients': bumper_clients + } } if run_async: @@ -197,15 +200,17 @@ class MQTTServer(): class BumperMQTTServer_Plugin: def __init__(self, context): self.context = context - try: - self.bots = self.context.config['bots'] + try: + self.clients = self.context.config['clients'] except KeyError: - self.context.logger.warning("'bots' section not found in context configuration") + self.context.logger.warning("'clients' section not found in context configuration") async def on_broker_client_connected(self, client_id): + logging.debug('Bumper Connection: %s connected' % client_id) - connected_bots = self.bots['connected_bots'].get() + connected_bots = self.clients['connected_bots'].get() + connected_clients = self.clients['connected_clients'].get() didsplit = str(client_id).split("@") #If this isn't a fake user (fuid) then add as a bot if not (str(didsplit[0]).startswith("fuid") or str(didsplit[0]).startswith("helper")): @@ -221,20 +226,51 @@ class BumperMQTTServer_Plugin: if botactive == False: connected_bots.append(newbot.asdict()) + logging.info("Adding bot to list: {}".format(newbot.asdict())) - self.bots['connected_bots'].set(connected_bots) + self.clients['connected_bots'].set(connected_bots) + else: + tmpuserdetail = str(didsplit[1]).split("/") + newuser = bumper.VacBotUser() + newuser.userid = didsplit[0] + newuser.realm = tmpuserdetail[0] + newuser.resource = tmpuserdetail[1] + + clientactive = False + for client in connected_clients: + if client['userid'] == newuser.userid: + clientactive = True + + if clientactive == False: + connected_clients.append(newuser.asdict()) + logging.info("Adding client to list: {}".format(newuser.asdict())) + + self.clients['connected_clients'].set(connected_clients) + + + logging.debug('Connected Bots: %s' %self.clients['connected_bots'].get()) + logging.debug('Connected Clients: %s' %self.clients['connected_clients'].get()) + - logging.debug('Connected Bots: %s' %self.bots['connected_bots'].get()) async def on_broker_client_disconnected(self, client_id): logging.debug('Bumper Connection: %s disconnected' % client_id) - connected_bots = self.bots['connected_bots'].get() + connected_bots = self.clients['connected_bots'].get() + connected_clients = self.clients['connected_clients'].get() didsplit = str(client_id).split("@") #If the did is in the list, remove it for bot in connected_bots: if didsplit[0] == bot['did']: - logging.debug("Removing bot from list: {}".format(bot)) + logging.info("Removing bot from list: {}".format(bot['did'])) connected_bots.remove(bot) - self.bots['connected_bots'].set(connected_bots) + self.clients['connected_bots'].set(connected_bots) - logging.debug('Connected Bots: %s' %self.bots['connected_bots'].get()) \ No newline at end of file + logging.debug('Connected Bots: %s' %self.clients['connected_bots'].get()) + + for client in connected_clients: + if didsplit[0] == client['userid']: + logging.info("Removing client from list: {}".format(client['userid'])) + connected_clients.remove(client) + self.clients['connected_clients'].set(connected_clients) + + logging.debug('Connected Clients: %s' %self.clients['connected_clients'].get()) \ No newline at end of file diff --git a/bumper/xmppserver.py b/bumper/xmppserver.py index e958516..9dde3fa 100644 --- a/bumper/xmppserver.py +++ b/bumper/xmppserver.py @@ -23,8 +23,9 @@ class XMPPServer(): connection, client_address = self.socket.accept() # disconnect any clients with this ip for client in self.clients: - if client.address == client_address[0]: + if client.address == client_address[0]: client.disconnect() + client.join() thread_id = uuid.uuid4() client = Client(thread_id, connection, client_address) @@ -40,12 +41,15 @@ class XMPPServer(): logging.info('XMPPServer: bye') def disconnect(self): - logging.info('XMPPServer: waiting for all client threads to exit') - for client in self.clients: - client.disconnect() - client.join() - self.exit_flag = True - logging.info('XMPPServer: shutting down...') + try: + logging.info('XMPPServer: waiting for all client threads to exit') + for client in self.clients: + client.disconnect() + client.join() + self.exit_flag = True + logging.info('XMPPServer: shutting down...') + except Exception as e: + logging.exception("Exception: {}".format(e)) class Client(threading.Thread): @@ -62,49 +66,72 @@ class Client(threading.Thread): def __init__(self, thread_id, connection, client_address): threading.Thread.__init__(self) self.id = thread_id - self.name = "XMPP Thread {}".format(self.id) + self.name = "XMPP Thread {}".format(client_address[0]) self.type = self.UNKNOWN self.state = self.IDLE self.connection = connection self.address = client_address[0] + self.clientresource = "" def send(self, command): - logging.debug('to {}: {}'.format(self.address, command)) - self.connection.send(command.encode()) + try: + logging.debug('to {}: {}'.format(self.address, command)) + self.connection.send(command.encode()) + except OSError as e: + logging.error('XMPPServer: {}'.format(e)) + except Exception as e: + logging.exception("Exception: {}".format(e)) + + def disconnect(self): - logging.info('{} disconnecting'.format(self.address)) - self.connection.close() - self._set_state('DISCONNECT') + try: + logging.info('{} with resource {} disconnecting'.format(self.address, self.clientresource)) + self.connection.close() + self._set_state('DISCONNECT') + + except Exception as e: + logging.exception("Exception: {}".format(e)) def _tag_strip_uri(self, tag): - if tag[0] == '{': - uri, ignore, tag = tag[1:].partition('}') - return tag + try: + if tag[0] == '{': + uri, ignore, tag = tag[1:].partition('}') + return tag + except Exception as e: + logging.exception("Exception: {}".format(e)) def _set_state(self, state): - new_state = getattr(Client, state) - if self.state > new_state: - raise Exception('{} illegal state change {}->{}'.format(self.address, self.state, new_state)) - logging.info('{} state: {}'.format(self.address, state)) - self.state = new_state + try: + new_state = getattr(Client, state) + if self.state > new_state: + raise Exception('{} illegal state change {}->{}'.format(self.address, self.state, new_state)) + logging.debug('{} state: {}'.format(self.address, state)) + self.state = new_state + if new_state == '5': + self.join() + except Exception as e: + logging.exception("Exception: {}".format(e)) def _handle_ctl(self, xml, data): - ctl = xml[0][0] - if ctl.get('admin') and self.type == self.BOT: - logging.info('admin username received from bot: {}'.format(ctl.get('admin'))) - XMPPServer.client_id = ctl.get('admin') - return - # forward - for client in XMPPServer.clients: - if client.address != self.address and client.state == client.READY: - if client.type == self.BOT: - data = data.decode('utf-8') - id_index = data.find('id') - if id_index > -1: - data = data[:id_index] + 'from="' + XMPPServer.client_id + '" ' + data[id_index:] - data = data.encode() - client.send(data.decode('utf-8')) + try: + ctl = xml[0][0] + if ctl.get('admin') and self.type == self.BOT: + logging.debug('admin username received from bot: {}'.format(ctl.get('admin'))) + XMPPServer.client_id = ctl.get('admin') + return + # forward + for client in XMPPServer.clients: + if client.address != self.address and client.state == client.READY: + if client.type == self.BOT: + data = data.decode('utf-8') + id_index = data.find('id') + if id_index > -1: + data = data[:id_index] + 'from="' + XMPPServer.client_id + '" ' + data[id_index:] + data = data.encode() + client.send(data.decode('utf-8')) + except Exception as e: + logging.exception("Exception: {}".format(e)) def _handle_ping(self, xml, data): @@ -118,14 +145,18 @@ class Client(threading.Thread): def _handle_result(self, data): # forward - for client in XMPPServer.clients: - if client.address != self.address and client.state == client.READY: - client.send(data.decode('utf-8')) + try: + for client in XMPPServer.clients: + if client.address != self.address and client.state == client.READY: + client.send(data.decode('utf-8')) + except Exception as e: + logging.exception("Exception: {}".format(e)) def run(self): try: logging.info('client connected: {}'.format(self.address)) self._set_state('CONNECT') + data = "" while True: time.sleep(0.2) if not self.connection._closed: @@ -143,6 +174,7 @@ class Client(threading.Thread): self.send('') continue xml = ET.fromstring(data) + logging.debug("XMPPXML: {}".format(data)) if len(xml): child = self._tag_strip_uri(xml[0].tag) else: @@ -150,6 +182,11 @@ class Client(threading.Thread): if xml.tag == 'iq': res = None if child == 'bind': + clientbindxml = xml.getchildren() + clientresourcexml = clientbindxml[0].getchildren() + self.clientresource = clientresourcexml[0].text + self.name = "XMPP Thread {}".format(self.clientresource) + logging.info("XMPP Client {} using resource {}".format(self.address, self.clientresource)) res = '{}'.format(xml.get('id'), XMPPServer.bot_id) self._set_state('BIND') elif child == 'session': @@ -167,12 +204,12 @@ class Client(threading.Thread): if len(xml) and xml[0].tag == 'status': # bot announcing arrival self.type = self.BOT - logging.info('{} type set to BOT (based on presence tag)'.format(self.address)) + logging.debug('{} type set to BOT (based on presence tag)'.format(self.address)) # send a command from an unknown user - the response will contain the correct admin username self.send(''.format(uuid.uuid4(), 'unknown@ecouser.net', XMPPServer.bot_id)) elif xml.get('type') == 'available': self.type = self.CONTROLLER - logging.info('{} type set to CONTROLLER (based on presence tag)'.format(self.address)) + logging.debug('{} type set to CONTROLLER (based on presence tag)'.format(self.address)) except ET.ParseError as e: logging.debug('error: {}'.format(e)) except Exception as e: @@ -181,8 +218,14 @@ class Client(threading.Thread): except OSError as e: logging.error('XMPPServer: {}'.format(e)) self._set_state('DISCONNECT') + + except ConnectionResetError as e: + logging.error('XMPPServer: {}'.format(e)) + self._set_state('DISCONNECT') + except Exception as e: logging.error('XMPPServer: {}'.format(e)) self._set_state('DISCONNECT') + finally: self.disconnect() From f2ddf014654fe7206d016680abd915d867aa1f5d Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Sun, 3 Feb 2019 16:32:37 -0500 Subject: [PATCH 11/34] update xmpp log messages + split checkLogin update xmpp log messages + split checkLogin checkLogin disabled for now until there is a way to store users longer --- bumper/confserver.py | 21 ++++++++++++++++++++- bumper/xmppserver.py | 32 ++++++++++++++++---------------- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/bumper/confserver.py b/bumper/confserver.py index ec6b70a..454e3be 100644 --- a/bumper/confserver.py +++ b/bumper/confserver.py @@ -56,7 +56,7 @@ class ConfServer(): app.add_routes([ web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/login', self.handle_login), - web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/checkLogin', self.handle_login), + # web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/checkLogin', self.handle_checkLogin), web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/logout', self.handle_logout), web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/getAuthCode', self.handle_getAuthCode), web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/checkAgreement', self.handle_checkAgreement), @@ -101,6 +101,25 @@ class ConfServer(): return web.json_response(body) + async def handle_checkLogin(self, request): + # The app seems to remember it's last uid and accessToken + # If these don't match, it fails + countrycode = request.match_info.get('country', "us") + body = { + "code": "0000", + "data": { + "accessToken": "tempaccesstoken", #Random chars 32 length + "country": countrycode, + "email": "null@null.com", + "uid": "fuid_{}".format(''.join(random.sample(string.ascii_letters,6))), #Date(14)_RandomChars(32) + "username": "fusername_{}".format(''.join(random.sample(string.ascii_letters,6))) #Random chars 8 + }, + "msg": "操作成功", + "time": bumper.get_milli_time(time.time()) + } + + return web.json_response(body) + async def handle_logout(self, request): body = {"code": "0000","data": None,"msg": "操作成功", "time": bumper.get_milli_time(time.time())} #TODO - when logging out close out any other connections MQTT/XMPP diff --git a/bumper/xmppserver.py b/bumper/xmppserver.py index 9dde3fa..eb0db11 100644 --- a/bumper/xmppserver.py +++ b/bumper/xmppserver.py @@ -49,7 +49,7 @@ class XMPPServer(): self.exit_flag = True logging.info('XMPPServer: shutting down...') except Exception as e: - logging.exception("Exception: {}".format(e)) + logging.exception("XMPPServer Exception: {}".format(e)) class Client(threading.Thread): @@ -75,23 +75,23 @@ class Client(threading.Thread): def send(self, command): try: - logging.debug('to {}: {}'.format(self.address, command)) + logging.debug('XMPPServer to {}: {}'.format(self.address, command)) self.connection.send(command.encode()) except OSError as e: logging.error('XMPPServer: {}'.format(e)) except Exception as e: - logging.exception("Exception: {}".format(e)) + logging.exception("XMPPServer Exception: {}".format(e)) def disconnect(self): try: - logging.info('{} with resource {} disconnecting'.format(self.address, self.clientresource)) + logging.info('XMPPServer: {} with resource {} disconnecting'.format(self.address, self.clientresource)) self.connection.close() self._set_state('DISCONNECT') except Exception as e: - logging.exception("Exception: {}".format(e)) + logging.exception("XMPPServer Exception: {}".format(e)) def _tag_strip_uri(self, tag): try: @@ -99,25 +99,25 @@ class Client(threading.Thread): uri, ignore, tag = tag[1:].partition('}') return tag except Exception as e: - logging.exception("Exception: {}".format(e)) + logging.exception("XMPPServer Exception: {}".format(e)) def _set_state(self, state): try: new_state = getattr(Client, state) if self.state > new_state: - raise Exception('{} illegal state change {}->{}'.format(self.address, self.state, new_state)) - logging.debug('{} state: {}'.format(self.address, state)) + raise Exception('XMPPServer: {} illegal state change {}->{}'.format(self.address, self.state, new_state)) + logging.debug('XMPPServer: {} state: {}'.format(self.address, state)) self.state = new_state if new_state == '5': self.join() except Exception as e: - logging.exception("Exception: {}".format(e)) + logging.exception("XMPPServer: Exception: {}".format(e)) def _handle_ctl(self, xml, data): try: ctl = xml[0][0] if ctl.get('admin') and self.type == self.BOT: - logging.debug('admin username received from bot: {}'.format(ctl.get('admin'))) + logging.debug('XMPPServer: admin username received from bot: {}'.format(ctl.get('admin'))) XMPPServer.client_id = ctl.get('admin') return # forward @@ -131,7 +131,7 @@ class Client(threading.Thread): data = data.encode() client.send(data.decode('utf-8')) except Exception as e: - logging.exception("Exception: {}".format(e)) + logging.exception("XMPPServer Exception: {}".format(e)) def _handle_ping(self, xml, data): @@ -150,11 +150,11 @@ class Client(threading.Thread): if client.address != self.address and client.state == client.READY: client.send(data.decode('utf-8')) except Exception as e: - logging.exception("Exception: {}".format(e)) + logging.exception("XMPPServer Exception: {}".format(e)) def run(self): try: - logging.info('client connected: {}'.format(self.address)) + logging.info('XMPPServer: client connected: {}'.format(self.address)) self._set_state('CONNECT') data = "" while True: @@ -162,7 +162,7 @@ class Client(threading.Thread): if not self.connection._closed: data = self.connection.recv(4096) if data: - logging.debug('from {}: {}'.format(self.address, data.decode('utf-8'))) + logging.debug('XMPPServer: from {}: {}'.format(self.address, data.decode('utf-8'))) try: if self.state == self.CONNECT: if data.decode('utf-8').find('jabber:client') > -1: @@ -204,12 +204,12 @@ class Client(threading.Thread): if len(xml) and xml[0].tag == 'status': # bot announcing arrival self.type = self.BOT - logging.debug('{} type set to BOT (based on presence tag)'.format(self.address)) + logging.debug('XMPPServer: {} type set to BOT (based on presence tag)'.format(self.address)) # send a command from an unknown user - the response will contain the correct admin username self.send(''.format(uuid.uuid4(), 'unknown@ecouser.net', XMPPServer.bot_id)) elif xml.get('type') == 'available': self.type = self.CONTROLLER - logging.debug('{} type set to CONTROLLER (based on presence tag)'.format(self.address)) + logging.debug('XMPPServer: {} type set to CONTROLLER (based on presence tag)'.format(self.address)) except ET.ParseError as e: logging.debug('error: {}'.format(e)) except Exception as e: From d8ce4bdfd0344477c1eb153c73a4c68aff364a45 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Tue, 5 Feb 2019 07:35:18 -0500 Subject: [PATCH 12/34] Update README.md --- README.md | 152 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 87 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index f8e0426..2b911ff 100644 --- a/README.md +++ b/README.md @@ -1,109 +1,131 @@ -Bumper -===== -A standalone implementation of the central server used by Ecovacs -Deebot cleaning robots to relay data between the robot and client. -Tested on Ecovacs Deebot M81 Pro but should be compatible with -most wifi-enabled robots that use the Ecovacs Android app. +# Bumper +A standalone implementation of the central server used by Ecovacs Deebot cleaning robots to relay data between the robot and client. -*Please note: this software is experimental and not ready -for production use. Use at your own risk.* +*Please note: this software is experimental and not ready for production use. Use at your own risk.* +## Compatibility +As work to reverse the protocols and provide a self-hosted central server is still in progress, Bumper has had limited testing. There are a number of EcoVacs models and varying protocols that it hasn't been tested against. So far two different protocols have been seen, **XMPP** and **MQTT**. Bumper should be compatible with most wifi-enabled robots that use the Ecovacs Android app, but has only been reported to work on the below: + +| Model | Protocol Used | +|--|--| +| Deebot M81 Pro | XMPP | +| Deebot 900/901 | MQTT | + +For more information about the protocols and how it works, see the "**How does it work?**" section at the end. If you test against another model and it works, please report it so it can be added to the list. ## Why? - For fun, mostly :) -But seriously, there are a serveral reasons for -elminating the central server: - -1. Convenience: It works without an internet connection or if -Ecovacs servers are down -2. Performance: No need for messages to travel to Ecovacs server -and back. -3. Security: We can completely isolate the robot from the public -Internet. +But seriously, there are a several reasons for eliminating the central server: +1. Convenience: It works without an internet connection or if Ecovacs servers are down +2. Performance: No need for messages to travel to Ecovacs server and back. +3. Security: We can completely isolate the robot from the public Internet. ## Requirements - - An Ecovacs wifi-enabled robot -- A computer on your local network to run the Bumper server +- A computer on your local network to run the Bumper server - Python 3 and pipenv - A network router that has functionality for overriding DNS queries -- A client that can connect to Bumper and talk to the robot over the -Ecovacs protocol. -I recomend [Sucks](https://github.com/wpietri/sucks). -It can run on the same computer and requires only minimal modification -to work with Bumper. - +- A client that can connect to Bumper and talk to the robot over the Ecovacs protocol. + - The Android or iOS apps can be used if configured properly. See "**Using with the official Android/iOS App**" below. + - [Sucks](https://github.com/wpietri/sucks) can also be used, which can act as a client and control the robots via command-line. ## Usage + - Run `pipenv install` to install dependencies + - Configure your Ecovacs vacuum using the official mobile app (if you haven't done this already) +### DNS +You need to configure your router to point DNS locally to where Bumper is running. +The easiest way is overriding the main domains used by EcoVacs using DNSMasq/PiHole, by adding address entries in a custom config: -- Run `pipenv install` to install dependencies + address=/ecouser.net/{bumper server ip} + address=/ecovacs.com/{bumper server ip} -- Configure your Ecovacs vacuum using the official mobile app -(if you haven't done this already) +If this isn't an option, you'll need to configure your router DNS to point a number of domains used by the app/robot to the Bumper server. +**Note:** Depending on country, your phone may be using a different domain. Most of these domains contain country-specific placeholders. + - Example: If you see `eco-{countrycode}-api.ecovacs.com` and you live in the US/North America you would use: `eco-us-api.ecovacs.com` -- Configure your router DNS to point the domain lbo.ecovacs.net to -the machine that will run the Bumper server +| Address | Description | +|--|--| +| `lbo.ecovacs.net` | Load-balancer that is checked by the app/robot | +| `eco-{countrycode}-api.ecovacs.com` | Used for Login | +| `portal-{countrycode}.ecouser.net` | Used for Login and Rest API | +| `msg-{countrycode}.ecouser.net` | Used for XMPP | +| `mq-{countrycode}.ecovacs.com` | Used for MQTT | +### Starting Bumper - Start Bumper with `pipenv run python bumper.py` -- Reboot your robot (remove and re-insert the battery pack, -then power it on) +- Reboot your robot + - **Note:** Some models may require removing and re-inserting the battery pack. + - This doesn't seem to be required for models that don't have easily accessible batteries such as the 900/901. -- If your configuration is correct, the robot will connect to Bumper -within about 30 seconds. Bumper will output informaiton about the -connection status. +- If your configuration is correct, the robot will connect to Bumper within about 30 seconds. Bumper will output information about the connection status. - Configure your client - see below. - ## Using with Sucks - Instructions (verified to work with Sucks 0.8.3) - - Download and install [Sucks](https://github.com/wpietri/sucks) -- See the [example script](examples/sucks.py) for how to connect +- See the [example script](examples/sucks.py) for how to connect +## Using with the official Android/iOS App +Bumper *can* be used with the official app, but with limitations. Your phone needs to use your DNS server with custom settings, and you ***must*** import Bumper's CA cert and trust it before the app will work. +### DNS +- Configure your DNS server as described above in the DNS section. +### Import the Bumper CA Cert +- E-mail yourself the Bumper CA cert (located at `./certs/CA/cacert.pem`) +- Import the cert as a CA, and trust it + - Instructions here are different for iPhone vs Android -## Using with the official Android/iOS App +### Use the app -Bumper *can* be used with the official app, but with limitations. Your -phone needs to use your DNS server with custom settings, and the app -authenticates via Ecovacs central servers every time you start it. + - Open the app + - At this time there is no authentication layer, you can enter any email address and password (as long as it is 6 characters) and you will be authenticated + - If your robot has already checked into Bumper, then it will be available in the list of robots +- The app now does a ping to the robot to make sure it is online, and if it is you can now control the robot -- Configure your DNS server to point the domains msg-na.ecouser.net and -msg-ww.ecouser.net to the machine running Bumpy. Note: Depending on -country, your phone may be using a different domain. +## How does it work? +**App/Authentication** +EcoVacs servers provide authentication of accounts and match those up to registered robots. Once authenticated, users can control the robots via the app. ***Bumper*** provides an implementation of the central servers providing authentication and matching. -- Login to the app. It will authenticate and ask for a list of robots -from Ecovacs central servers. +**Robots** +So far two protocols have been identified that various models of EcoVacs robots use: **XMPP** and **MQTT**. These appear to be mutually exclusive and your robot model will use one of these two protocols for communication. ***Bumper*** provides an implementation of both protocols handling communication between the app and robots. -- The app will now connect to Bumper and try to ping the robot. Bumper -responds to this ping to tell the app that the robot is online. +---- +**Login/Authentication/RestAPI** +***Bumper*** provides a fully simulated central server that handles login/authentication for the app/clients. At this time no authentication layer is implemented and you can use any e-mail/password when logging in. Future versions should add additional options here for security. -- You should now be able to control the robot from the app. +This means however, that once a robot has been configured to access your WiFi it never needs to communicate with EcoVac's servers again. +---- +**XMPP** +*Example Model:* Deebot M81 Pro -## How does it work? +The robot utilizes XMPP for control. When the robot boots up it sends a HTTP request to `lbo.ecovacs.net:8007` asking for the IP address and port of the XMPP server. Because of our DNS override, this request will be received by Bumper. We tell the robot to connect over XMPP to our local machine. -Ecovacs robots communicate over the XMPP (jabber) protocol. Messages -are relayed by a central XMPP server. +Both the app and robot connect to the central XMPP server, which relays messages between the app and robot. The messages contain commands for the robot to execute or responses and statuses of the robot. -Bumper exposes a simulated XMPP server that implements the nessecary -functions for relaying messages between a robot and a client. +***Bumper*** exposes a simulated XMPP server that implements the necessary functions for relaying messages between a robot and a client, acting as the central server. -When the robot boots up it sends a HTTP request to lbo.ecovacs.net:8007 -asking for the IP address and port of the XMPP server. Because of our -DNS override, this request will be received by Bumper. We tell the robot -to connect over XMPP to our local machine. Voilá! +**Note:** It's been observed that the apps will attempt to utilize XMPP regardless of robot model. For models that utilize MQTT, no activity is performed over XMPP just pings. +---- +**MQTT** +*Example Model:* Deebot 900/901 + +The robot utilizes MQTT for control. On startup it connects to an MQTT broker (`mq-{countrycode}.ecovacs.com`) and subscribes to a `p2p` topic where commands are issued. +The app also connects to the MQTT broker and subscribes to a `attr` topic where the robot will periodically post status and location updates. + +The app receives status updates, for example battery status or robot position information, periodically via the `attr` topic. + +However, commands issued via the app/client are not published directly to the `p2p` topic that the robot subscribes to. When the app sends a command such as "Clean", this is sent via a Rest API to the server at `portal-{countrycode}.ecouser.net`. + +The Rest API provided by `portal-{countrycode}.ecouser.net` receives the command and passes it to a "helper" bot that has permission to publish to the `p2p` topic the robot is subscribed to. The "helper" bot waits for a response to be published by the robot and then passes that response back to the server for the Rest API to send back as a response. + +***Bumper*** provides a simulated RestAPI and "helper" bot, performing the same function as the central server above. ## Thanks - -Bumper woulden't exist without [Sucks](https://github.com/wpietri/sucks), -an open source client for Ecovacs robots. Big thanks to @wpietri and -contributors! \ No newline at end of file +Bumper wouldn't exist without [Sucks](https://github.com/wpietri/sucks), an open source client for Ecovacs robots. Big thanks to @wpietri and contributors! From 605a28f570b56c2c906f700446ec1f6128deeb4a Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Tue, 5 Feb 2019 07:36:56 -0500 Subject: [PATCH 13/34] Update README.md --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2b911ff..1c1ac45 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Bumper *can* be used with the official app, but with limitations. Your phone nee - Instructions here are different for iPhone vs Android ### Use the app - + - Open the app - At this time there is no authentication layer, you can enter any email address and password (as long as it is 6 characters) and you will be authenticated - If your robot has already checked into Bumper, then it will be available in the list of robots @@ -89,19 +89,23 @@ Bumper *can* be used with the official app, but with limitations. Your phone nee ## How does it work? **App/Authentication** + EcoVacs servers provide authentication of accounts and match those up to registered robots. Once authenticated, users can control the robots via the app. ***Bumper*** provides an implementation of the central servers providing authentication and matching. **Robots** + So far two protocols have been identified that various models of EcoVacs robots use: **XMPP** and **MQTT**. These appear to be mutually exclusive and your robot model will use one of these two protocols for communication. ***Bumper*** provides an implementation of both protocols handling communication between the app and robots. ---- **Login/Authentication/RestAPI** + ***Bumper*** provides a fully simulated central server that handles login/authentication for the app/clients. At this time no authentication layer is implemented and you can use any e-mail/password when logging in. Future versions should add additional options here for security. This means however, that once a robot has been configured to access your WiFi it never needs to communicate with EcoVac's servers again. ---- **XMPP** + *Example Model:* Deebot M81 Pro The robot utilizes XMPP for control. When the robot boots up it sends a HTTP request to `lbo.ecovacs.net:8007` asking for the IP address and port of the XMPP server. Because of our DNS override, this request will be received by Bumper. We tell the robot to connect over XMPP to our local machine. @@ -114,6 +118,7 @@ Both the app and robot connect to the central XMPP server, which relays messages ---- **MQTT** + *Example Model:* Deebot 900/901 The robot utilizes MQTT for control. On startup it connects to an MQTT broker (`mq-{countrycode}.ecovacs.com`) and subscribes to a `p2p` topic where commands are issued. From c42c02278ae80ca5ed1703aeac87243900d2a893 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Tue, 5 Feb 2019 07:38:24 -0500 Subject: [PATCH 14/34] Update README.md --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1c1ac45..020f8f8 100644 --- a/README.md +++ b/README.md @@ -97,14 +97,14 @@ EcoVacs servers provide authentication of accounts and match those up to registe So far two protocols have been identified that various models of EcoVacs robots use: **XMPP** and **MQTT**. These appear to be mutually exclusive and your robot model will use one of these two protocols for communication. ***Bumper*** provides an implementation of both protocols handling communication between the app and robots. ---- -**Login/Authentication/RestAPI** +### Login/Authentication/RestAPI ***Bumper*** provides a fully simulated central server that handles login/authentication for the app/clients. At this time no authentication layer is implemented and you can use any e-mail/password when logging in. Future versions should add additional options here for security. This means however, that once a robot has been configured to access your WiFi it never needs to communicate with EcoVac's servers again. ---- -**XMPP** +### XMPP *Example Model:* Deebot M81 Pro @@ -116,8 +116,8 @@ Both the app and robot connect to the central XMPP server, which relays messages **Note:** It's been observed that the apps will attempt to utilize XMPP regardless of robot model. For models that utilize MQTT, no activity is performed over XMPP just pings. ----- -**MQTT** + +### MQTT *Example Model:* Deebot 900/901 From c19ba40a38e217f8e239069dec35614c8a57bdb4 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Tue, 5 Feb 2019 08:42:29 -0500 Subject: [PATCH 15/34] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 020f8f8..280ba21 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ As work to reverse the protocols and provide a self-hosted central server is sti | Deebot M81 Pro | XMPP | | Deebot 900/901 | MQTT | -For more information about the protocols and how it works, see the "**How does it work?**" section at the end. If you test against another model and it works, please report it so it can be added to the list. +For more information about the protocols and how it works, see the "**[How does it work?](README.md#how-does-it-work?)**" section at the end. If you test against another model and it works, please report it so it can be added to the list. ## Why? For fun, mostly :) @@ -116,7 +116,7 @@ Both the app and robot connect to the central XMPP server, which relays messages **Note:** It's been observed that the apps will attempt to utilize XMPP regardless of robot model. For models that utilize MQTT, no activity is performed over XMPP just pings. - +---- ### MQTT *Example Model:* Deebot 900/901 From 53d747238fed5cb437ad234ab023194420f5ec8e Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Tue, 5 Feb 2019 08:43:43 -0500 Subject: [PATCH 16/34] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 280ba21..e198492 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ As work to reverse the protocols and provide a self-hosted central server is sti | Deebot M81 Pro | XMPP | | Deebot 900/901 | MQTT | -For more information about the protocols and how it works, see the "**[How does it work?](README.md#how-does-it-work?)**" section at the end. If you test against another model and it works, please report it so it can be added to the list. +For more information about the protocols and how it works, see the "**[How does it work?](README.md#how-does-it-work)**" section at the end. If you test against another model and it works, please report it so it can be added to the list. ## Why? For fun, mostly :) From 29689b3469ed0e618413375cbf9c56936d48f36d Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Tue, 5 Feb 2019 08:45:21 -0500 Subject: [PATCH 17/34] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e198492..8105ef9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ As work to reverse the protocols and provide a self-hosted central server is sti | Deebot M81 Pro | XMPP | | Deebot 900/901 | MQTT | -For more information about the protocols and how it works, see the "**[How does it work?](README.md#how-does-it-work)**" section at the end. If you test against another model and it works, please report it so it can be added to the list. +For more information about the protocols and how it works, see the [How does it work?](README.md#how-does-it-work) section at the end. If you test against another model and it works, please report it so it can be added to the list. ## Why? For fun, mostly :) @@ -30,7 +30,7 @@ But seriously, there are a several reasons for eliminating the central server: - Python 3 and pipenv - A network router that has functionality for overriding DNS queries - A client that can connect to Bumper and talk to the robot over the Ecovacs protocol. - - The Android or iOS apps can be used if configured properly. See "**Using with the official Android/iOS App**" below. + - The Android or iOS apps can be used if configured properly. See [Using with the official Android/iOS App](#using-with-the-official-androidios-app) below. - [Sucks](https://github.com/wpietri/sucks) can also be used, which can act as a client and control the robots via command-line. ## Usage From 79acf430d9c2bb4a972d0eb091a03cec5d88f0a7 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Tue, 5 Feb 2019 08:59:38 -0500 Subject: [PATCH 18/34] Updating docs Updating docs --- README.md | 11 ++++++++--- docs/images/emailcert.png | Bin 0 -> 37810 bytes docs/images/ios_email_cert.png | Bin 0 -> 22078 bytes 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 docs/images/emailcert.png create mode 100644 docs/images/ios_email_cert.png diff --git a/README.md b/README.md index 8105ef9..3977187 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ As work to reverse the protocols and provide a self-hosted central server is sti | Deebot M81 Pro | XMPP | | Deebot 900/901 | MQTT | -For more information about the protocols and how it works, see the [How does it work?](README.md#how-does-it-work) section at the end. If you test against another model and it works, please report it so it can be added to the list. +For more information about the protocols and how it works, see the [How does it work?](#how-does-it-work) section at the end. If you test against another model and it works, please report it so it can be added to the list. ## Why? For fun, mostly :) @@ -74,16 +74,21 @@ Instructions (verified to work with Sucks 0.8.3) ## Using with the official Android/iOS App Bumper *can* be used with the official app, but with limitations. Your phone needs to use your DNS server with custom settings, and you ***must*** import Bumper's CA cert and trust it before the app will work. ### DNS -- Configure your DNS server as described above in the DNS section. +- Configure your DNS server as described above in the [DNS](#dns) section. ### Import the Bumper CA Cert - E-mail yourself the Bumper CA cert (located at `./certs/CA/cacert.pem`) +![Example of emailing CA cert](docs/images/emailcert.png) - Import the cert as a CA, and trust it - Instructions here are different for iPhone vs Android +##### Importing the CA Cert on iOS +- Open the e-mail on your iOS device, and click the attached cert +![Example of email on iOS device](docs/images/ios_email_cert.png) + ### Use the app - Open the app - - At this time there is no authentication layer, you can enter any email address and password (as long as it is 6 characters) and you will be authenticated + - At this time there is no authentication layer, you can enter any e-mail address and password (as long as it is 6 characters) and you will be authenticated - If your robot has already checked into Bumper, then it will be available in the list of robots - The app now does a ping to the robot to make sure it is online, and if it is you can now control the robot diff --git a/docs/images/emailcert.png b/docs/images/emailcert.png new file mode 100644 index 0000000000000000000000000000000000000000..aebd1f30002a1d70a5dd41c0ef259bed7dc7830c GIT binary patch literal 37810 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

A_E`uqx}`y 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 BnP~t3 literal 0 HcmV?d00001 diff --git a/docs/images/ios_email_cert.png b/docs/images/ios_email_cert.png new file mode 100644 index 0000000000000000000000000000000000000000..64fc93760c2c1d2b393f5c37ef643c0cc8683121 GIT binary patch literal 22078 zcmagG1yEeU^DYX6z~UC%77M}M-5r92;1WDIEWshTyL*BL_XM}#ZUGY9-4^$I_V>T< z-haJ!Z#}BEYWJM(p6Nbwrstclhluy8au}#2sBmy_7z*;z8gOv%;=tz}2no2t!1pc( z_+e@zCH3A`N>0kr&e28F$<)k3+Q!w%&eZcg7aZKX1Tzy84F%?RgQlh?CWB*445%*d z8sXv58YcdOU6{db=%3J0BZm53$HK#liqoO9li>Tb_IH8u*$x9o1?hMi6&j7Y=06pm z{fVlU&SL*l09TbfyI~G~LJrqVLW@)+A|lfKYPStdM8Ix+HAujKAc#7c`|4AP)B>sa z5iRHeq@yAq&lT_cYEWB#TplsA`}Wn~oPs2-n%pO;r~*}aM(aApWqbgz;N zwUfVE?>+_&=Z{`SH0S;D;;#(^DsQyw%)RI3rIYaK<;9)j{RKHv=qpb+d@73*o`IRG4;BYk zn*TEL|JaeXa5ZzWae~-5I)GtzO+PuhK}4vjVGaGSzyIo|g@?`mZpp#*f4T)Y$OgN^ z#=*+Y_J2}?*jWBw)L?i1OYJ{?{a16sFk^!6Z9FXObfsbAv_VgM$r3lv9}P|8)8Pt)=E-V*!K~ zrX~vX@joj6{o4PO7iNP+_rFE^zaHg(t^&_e6jhk*e~k-K)Uz$EeKNFU8nzXd=M)^f-)on+hzTR~_c~l@nU{`p;XXmK>qvxDnXZy9ISou+7Z%uF`M>1a!$>8O{;5EcO{BGa|DgiuwS77^TdM^U z_mh#4DQ#OC!?ET;(4`V*v@tX^Ea#fRp_$!x=iKi|=_Y@XqAGkC~^*=5*2`Kh|+N9b&5Nf{T9qt&0+r)NLME8gM(D8-i|+SS-|-a{An!!4=(Ey!tqw zu?kAIEbwS`KTQpz^wT+M+Qi&n{8g>>@gp)qFqo=OHAgVSoT@WYXd zqk=zwmLX^}Wb(eDbE>V-X#0Fcy7Ws&7W4CH-rcXOruQ?RXF z35gXAqT3szn?@CaZu0V(RZh=?|#3Q12Z9p_l0HqKi_pvFNzKIms=Mt zx4JumX+hKB)nzS;W#wf>w)0gAb#?r*Uu5%pPp6BN3S2~fW7Ngq^ntIk1R2DGO zs2o!&y^K&4k{9eROg5kL=L*{X_fMUkg0)>9b!nLbwsZ52S>L$V-PTZ%0)m|~-IFdi zM&@inMTq>4|E?wvXM=l!fv-a|v#ueNvHv%{lh!Ip!8AfTW2Ak}p$aUwz=uE8#+^=L zX{?4M6%rcGxTDN-A%!eVr!F+~%o2rVWs09ae@=X9vS0kEQq_K~=XqH9Yin2R#S>gD z_Hs87@Mz8ZayuZF%26;N|I1-{t8RofsU+7g(pur}s@k9BX315}+98fyh&O&KhHvFv zVYB5JN%KKjdEhu2Ym<1c4|E870?On6?4#T4VlnL^<;a#w2MVrQJ-y`zO}OcV8Y zLQAO=H32Kzh+xTPNQ&%{&{gYW`1n$O*%-@hd6=QAGTk04OIccV?nlO7K6R4MH+VT) zY4nlF3q2`CHV+WYMxfv)Oo_TmgH;H z@%l9^W4a{2#-_%Sv*-WHK8xvG0YE(W~W>Q4rK)Yw8AKBd~0%7Rf;G&_*nF zSW;Y|$+#KpZPKY#l__kG1+ot^F30CAGddWw?&V4LVrPGcCjN%e6SwI*Ipt z9_P5BHk0d0MapoQmq3MaLiQI(#^s>`BXtz_YN3E`R{K^-vFN&k&Ch`;`6@?BUnZN} zK)R+chJ%s1fyW7KYFvCcgN*Q^*9&Ifv9Qr<8XCCcXA2dQ%(n&;hAbIqubU@)StBU4 z$+v!aU414NgjhR?J>PBy@gR5GFSmT~x;mi4$N%hC>a$6@3YyOryPDN>nO2|=*&NN_ za!zE>EFQe+xDoL@Zk$Jem;AxE>Qj$SAuzq)0Ip$+6x_8qis}T-IOY!s z%7qt^xZBe)ZUJz7j@3zQtHvx`VfYFmxLXxaq||7O@E@&Ahf;Vx)f7XdlFuy=M%04e zSRDp5Xg_C1UMDFc+%2E`#0Xby&8d7BbT)`+>u6JTxHU^zavfQ=3VRr7Zc>uSpa9S4_;1hUT7-9_4+~0d^v-#If;CwnW0!;e1%fvU-86 zTez^-TbEY>6}W5OD;K%6s1O4#BIvKqCl>5)7}D7;!wgjDe!-p_5%gO#dCw5ASdR(A zCy89&>%UZFW8@&Gfmm{Agqja^HXc%-Mc3=vFgSOJY8Eexa!yyZ>+U!-W5OqNMtf+x zBzXvXf~^mQL^;q*a=LfhQbH(C!)K6`GzXbrB3_PIb4u3V<#u7~c&+!Vjk)tTD1 zKd$>)aGJSLFDn!%ZcS(149U7tiq^<`xiKU zS<$$HCpjm6ucbHWu+|k|dICOaI>+jsG7f{Bu85G`ad6h7<|)!UJmMj7_JC>RC zvWLI;I$Fh4e#(b9Sm}+nt@ByD_KEmR+QEX@bYi#;QzBz}n1;@*RPpyV!|ze7=csi| zwG5tP8a5Qt|9k;P`!JA<(i=#|T7Gj%+d<25^bN4)&ynGjCYF@*LTJ>+BGeUY{&6?y zI`$sUOJ&t$H9D#;$Fvcx>HHYm>iAb3dM_SaV-i&H=WQ`Y_}Uu>$%0?67f%-^eVg(Y z{Rl}Y@tt`R%#-ezJ|-@eXxet%n#A}#1#;dEKL`$`l%$MYMikZ6a&n9zFDDNS_ty5Z zUMt;B8WP=cz{wuwYdcCOKkB)#VHrAz6d8zv%MqRrGiuj%;#<&luu%pCDV@%WFY3=bTttnnFWDDhTL1?lMN;ys9t#l!0v1-5l9U zTY;+IgH8(bTrv9vCQjz=BTevPxwf@;L;~fQk*puy7GG~x`>~r8ly35I!eo>nF!E7lsrP_xM<--4Y*MHbb?BVYg&)jy0??d8zcDkxi<}RsY8xE35 z(@LXvn$OF_YMQ2+2#<2XcTd&ZRBS4|ZzX$g5<)#!x^c}fFL!QIM%*cXBzBJdd6tbf z+K#B+BNH?aRu#D0E-ixIwU(I|USE-O6v}t-7I2$hm5Q~5TEBd9?f)~_Ap@QNXuIu4 zZqJLrH1o*3$lx+|P6th>A1O*37mVZ$5I9Xrk9Y%>Bq`v*!Zr=T?0Eb&&h(fWE3W2U z^wvk+It9+*ds5qc=D?ZE4NrAk6wdmA{-U5wAdf z9wN@*#8Z`1ce2@DDTAe#9&S$a$b=;*_u1`>ttN7*_5^c;N8nI3KQW8UOwA?~pHe)L zRo2Hwr+&_Q<2?up=;-=$YN9Ub)n`hAA}xcLyh-vDU7^QPzWxd?h+Ic^l&bL5p&IkE z6NP=1>;=5(@f<`T-xw<6D9N-!`Q?M_@RLa}ig`k#Ha2NERxTWHX}Nb2s+pdf8-ALRie-WFB++)^jA<v^3oB})gfpX7$3tiZIVqWipL;< z8)1Hg3@Kod4ypx@@hE`>dkRs*FlWqK^4CY%PC{F+L_(GK)P(IG|MfI(rKi$13`S_s z4^n@oTVH`w2$VO$b8BGVhsa)-ofkMvxk5tNy}0X8?;;ANZeR8M5L*vVnoT$`SkCv{ zscR2@AZ$N9PQg;+yTKVmh2IfnHbMZ<-aACZIzdze5S$k=IRFXW0r)q%?7b`C4uCyg z>NUg#SvZjv003^WBB*|C_C6Kfjr~e9DGOk~MbI}nnA8#g3EACGb9P~n@LxCp8cE?V z_W-H*Rsc5coU~tL3n9dclLD6QXueV`15z%80on9jXU!i^HctQ3(ids)F3Mzwx4j&? zhz-!IL3PBs777zV<&7x(LbQ1jcYWqA0;2GK4DI~qLTFUzNN@8tO-_~FR`)dbvcaNf zix2lMg`pGJYsEnL#vRcGB_NtLbP|W+%^>S%{Qhd!{hbEh>I}y6<$-^K%fsS?IOeVl zuD15M!fiB@2{?o4udT5_QHiWZZT!s!I7;)I1JfTtbE{3xn>~R=1SOSlrsp*tR`iBV zNis5H2#ORu0lqD1tXV#`P+fciDjD1n9%4GW*sU?`q;d1NI$HSCa(wLbdq{LY-yL)p zO0DSDd>KllTjw|J33giLbnC=?t41+^Gl+yhOd~!*Mw&+Hxs@<97NuROhu_v8OHNAh zBgrzC7EAb6Tqox*foLN#Q%gUogZwNDKdrwGTYMuT3JtVXuG~DS%I@(d*@iCd9h4sbaHPmMvNTvoLe!Wo$yj`-y`Qv1g@}HYhJV zuHcmV_jeOBG4UN`i=R7oXQ4I8e&A16zgHpqrFsU|TQUj!{~Tx8h&!lGCK`O&TOxdJ zk5ub)ZqV+w3t9@F{~Jg|FxTNJs7rr38_RRV9|TSo3guY@vO4%Bqj&` zh&@OIPoj=N!u{@O4++kq$iii;aenY}e`Y(=wo2jTQZ@;-HYSF-O2ps`t@35|p{zoD z9*dCwR5T~?W2GFLiFXgrYK~$oS+$G0n3%%>^q;e$q*-UeTF#5L`Zp3k_N2U(IkS2f3nuSn!!@fQ2 z;=&#r6F1g-2&D~f_Aj1Pl1<{UP1NBgxV!6X~L3&g-JLPynng)s|d|xL+qh7(xCQKYH3{Sm5(0_ z>yM*k+inqAN@d9kqO;Z~UJ!8z5X9O5LF5rR9pE(-DRQ+WXWUO$+5xaqGWZHh7))s3 zBmw%;hyQV~LbrZvTUZYpg;@-zHM(EWncU9;*xpZyC@@{6_2h?3PYj^4RHUvlnG zeL((nc^_io2AwGv8WL|bIK8t<$-Ww4^t`Xn*&309;0}H{P9F@g9-&*}vXVu^M*>7xKF9j5-z8=lB(|`R*&Z+$_5z^WEoFwVnuO@YrfM z#r9)iGWdb1LDmhp{U0GU>&bcA!(k;UgHxNhWN}6FDxRm&r6^FTJ^@8}kt>%BULhvP z%VJqDNBS&g?RlKZKE@n7rEtB$uQ+B*^Q^OOE>8kb9DLf?V)8q;_irB$`}eahJ5$li z>^{6;jlENdPcw>}#h6_c`_6x9xFM!<1D8A1Dvy%Pl%S->n#N|Njw7GU!Zd#M##Glldzf%5)VenQ$RTmB>6 z$6Fk7W!vw>ws!&M4pYVF`nEiI|2pRlF}kbDexn~_k0WGh2=Tr(V`ULdtZMhcom-td ztNX-W=zVh%@HpDprzrA`W-Onr$;j5lpJH=3C1t(_(1m1EIotP>D~2$6*1?r6KDqJmimBNZWtqChq# z@!_PEjemV&ZJ;R4i1DML+XtthH^v_8uXWqK+-k%<7W9~MOtV0EWK}dpoq8JdhrIJ4 zJ4}W3CKi>WGjF%UyZcOgudZ0G0@~J0GSjd|D^n;8aTP(UC>k+)$|)tB7A#pu(G(l! zlysfbNbXT_!!8)uA25j1K6^!*uU>s=H}LzMfj&89d{4cqqrrq_UzK)(;VI?*+nZ+Z z9etjC{JZ8U3)8IugYlJi@7C>kBs-DM1CroF0hF zEAu0_gNCxr^!Gcw%*M80?J5HXQeHc!k!sIDVd+@v_$kK#P1CdfCB8!2UsXLnzU#UO zn+WSnh(6fWu!4V=YLes8y`rN`|?PCpNY zn#9o@CpNZ` zaoK2MTHVr%Hzm;w_SsG<)GTj)XG+9+UhKR++P`oesSbhjs>ue%1fsR|LQtRE<i%{MUY!NH` zD&A<$qlvc#ndiXSC{pn@nZjkX8k*tD%Dp*{Yt>yyB~u{y6p@IAh$BA$lB=#fMbGIL z%ihCscAoLSUto6N7M9qQDB(MU|etKf~SSLeQl|9I`rtw%e)t-1&vI`v|Cf zMS19T{G^X#ZN2~VL(BlMgvW$GmT+-~@l@Z~aRzPBCRx&YDjJMF%YijPO|R1#Xbt0F zu!IbNB?9gv!oOjFg8_ggTp@*rfLcJd6aY(h9WtyI0KmZ+0>F~uc!&s0$`uAnmRK&( zVX&ka8vs0S5~~JaumlsJ!LnN|a!p|n~262Z(EfoaUmfj-XVEzE;@ zmHy^V z5@dt~t1XC@~n)4J)Cq@<)ktNovpkTA*K$x(QkPmAS-hOE|_g$V;Y^U%kO z3FM`>YPrnxc|LXMB;0#nn|dWi^`Z~A{#_PBUHRm`#p76ah|Hlvj!c&T1~c1pCj+`3 z=cD6yKa)4C!naIod_SGG^`js*@zn38Rm0GboIc4SA|6joSiR$J%u(^fhd=OlUYjv|TR;p_ zX5@Zm5%Gi>X5X7!|G0op4mV1AwLMhJhWM7 zY?^e^bA;T7`8v)qbQ`SIIjcgh3TWQffcAnCIZ7)lFlJpNGeS5Yj~)G^yyn>eYN%<|@8P&R z=dAc+MD25a6f5fUgh*Of?CP1O8-N3xxoA8zz2F#oaGhqBm^F}07504nI zZp^zp_@ksMa;4>Yx1DCOKV8)Q?|#hDkLG@IctGsgd4HxPZpmeYrOsu0*hIJeVfi!_ z3Uz6InD9@n-RvLw*J!&9EXoU%)7d3N&jPRpf2bD(hV;TA&ix~IU!hV;V@{!o<(Fjb zd~_k^G?$lu$$NRUs#&|gI;8Ks?PJ?oTz&p|dAce_{82%v&~F@%l*gi5iud2C(XNx0QLUv zl{TY?nJ;v;-fF_6@_7weES}V%Ng`I{dcpkuJNS7M^ahMTY>zN-zS!njKY97D$+_T% z44!-ha*7}OR^z-;5)6+7m@u;7!RpKN z&Ah&_a6@&?Gq8WF3o0-t+9<~-s!EaneFb}UoyI1BJ$RjJU;Xv_Aj(IRc0Hf(%`vaM zQk)J`%!6L6L@zSDj)Ty5uPS9H+x;-LlInRgPOQV&C#syl>IrZ)#|}43D4<|h@kk=BLp*Vac;|Bf65nxB z#YfJn65SZJA6DKuhU_dp-K?_B`#tL2jTk@U zKBkCEUa%N&Kdu#Hc{kKEgI0lB{ap$u!}xX$PtFu1%c<@r1@#H!X-G z3VuGsz59L_>+opgyN#dmGWTfm^l3YPHM6@os+e{86~XF~K)x9;a=TGRh;-XLYQOHc z->eX3PBi@mBNv>Rn5HMNnXj(&PMxSbT~f+@6USNTj7g)yK3XT?vQ!4+X)0aBsIO%? zb_j5r{e}?n;Kta64+MQakC-oU%%jD@!Q=?s%J!4M+n7Qoz|ZuKr;`4tua5`HxRq_V z)4Cw+pAn-rJIxGr0rmj3_Zv;{>R~8ofpUNDK4jfAx9*NZWpg6 z*2Py>8MflmGMv}Ziahq>yfZ<%v2YZge?^KB1e6NmgyFQwl$-WWFlxfN?cAJWZ1`Z_&@dI_dHJg;)$paywTx zoo4cAJv?=NUJrn`L`a`JaKuy5(;qh}U$Bvky5iJrm}O*}{GmyCh$E>x?bjv1R!lZ- zjri(6H`}CCuDcrUR2KbI^q|%#n)j1H-pk=or$%i_NB)5qa@YzvAQN1{UvrvGn7l`( z@Bj=_rEGP{^Nfh$`*Q| zB}Q;E#-?sR%#YudRm=8i8yU*D#wh92(yo`afUf(O)>) z#Q!=dJ1F!+5>id~lLVD)k;L+LYCNDGT8(9Xs4iH4D0&flxe%jTqKOm6T{`xoOSzAr zd2a$Tf7CbeH3z-?vdQsGvmT+u8920*gL}n@T@sWNV2#pZN=Y)I^sdV|db3O3KI2`X_E|HQRK= zc9st?b&Q2Q#*hhcZVf%&k%ZPwJ6bywCqbv^6^Jpg9`o0UCZ`gXzv2*3&bTn6%Q;!R zH~%|rp<$>=Ui=V`1p)8T+h1lGK}X3HGKaWx#zdsgfXM+4#$@n@6o`Bi?otu?s&ZE} zjpaPmX7Pqkur{*>wkWyXU)o$h5>zbS>E}rCwspg!sC{eOG?~}rZ95aWOXFw1e>;Wq zjux?w7u55rpR(tGJ=--}bU`-oJWQz91}rbM8{Yf-Ev0!Q`#xp8sviTpk60`=k)N}FYrhzy`f}621@=6?8m0e;tFRq6xtKd5 zDoTKq1H2{Pl65^-)_#)%Z1s(W2#tih*i7|c0Fchwd#cF=OCc==OAd0|4GRvGkH^3| z+%s4_jIJw)s@~^lv96=7_`m>^wC7;YsH4}JTA08KrMk3M5Y@-s!M6OHbg%|8FG5+; zq6;oWRmj(AC_W;X*l@Dn4K-Hk!RDo?YCLE0DURER;;Ye9#2#wtNH1%IL>x9AHu<^! zGSh!jiK7&vfDu{BZ<6lZMosJ0mzDQ~X9YJjKiJW>HTy_YuIve%AN`AM;;3f79|Ajo zOyl2iAR<3S^oxP>7(o3S78^t=LRDf^qpp!ZsyyHz*z z^V`E2+~X!HM_0D9q~`qOpsZvHW%1URrqq7Kq0m4Z;mI4U4HjkUe1Co(CpJhRS{Bq) z8s6Wt;fNb&buwiJ3YseW8(pH-q~*mTs`=qoEB4ew7Zez=q7o8!U>3-CK38o_Ia`a^ z>DP{1+)0Fky|Z7EhnbX>LFBjOJP@L`mG8xGrNI+55=fs^#v*!1nuv7BOKqY;a_)^I zY$mo}V@54>UVv9qpkA=Oi>L6`E)O!TuzoS{!&pn~ZvIc7#ltoEdWgILh7u%+!c@m3 zjgTJiuhn=qv8Yh=LG(_L>xG%55Yx>W0~LS&%ucKQW$pXwzKH(a3zZIu5{HYP*wBcQ z{dsbuqq+nW(qZLIJ8fDS_RX{By(VAEc$aF{fSA2XXl;M8v{F)|}s z1Gy0?qdV5DdouhewG}F(4mY(Ma!>BuJa=Y9&d>0AQ$HXyve6)dh-R5MZBM7oBmn}k za5gg;BK8+7>WD}BoT5Z8n-o@ibWyRwJE{;wI+0FZny z6hLqauSUCAy;i%@&nozIbk&K+QLJtzAql&ydr%WQw5|ibikNWKLfJH?8IcbN3F=0O z8&>OK#9Wrawgi)FePrvgLbWdJb*L}uqF%k9pj1a{e?|1Lm#^_Ri z=l{hL*qqNMlJ)6S*2E8JqQdDQeH%lv{Q1rIzkPEEy|wH#a-`2 z;Kg83EQP?<^7d>!Uuk2+BF&h9f{fq|Pj_IzKvZU=iE=@3wuz_yHi+=3Ct6 ztwbeBEyx8WJ_Xs}XfSd*>bM_`j+~u!uch+!dz{>cTH1wZkpDQ zI2Dbz6GjwvX#`~tPFe_-e4MLNR70&FP$Nhjo>Ogff33nCZ(>@b+7%Us(~a9e7`gmv zNg#~YT9ih3sVRYiU#7N)64*4`?Bh@})>;d7<8eP(s$plk*NH~p{C;$+iPU={rtc-WxBce~2`6R>sCa#%H)C*z-VRq%el zCVr25=Xg{=X}-Q!2#XRwnvV~Qoq&)4{wchrQx;4P#FjUe28(76RS=uP{JEfq$5WE6 zhr}2At@Dp1?mmxq(fl5V7sb00=E#B@351XPa+@TF$var2SX8pO*_#tQSZcO?8@PG? z&~d2Z@E>7%&NW9a#jXiS3~r9%2oVQ8M z&2mu{`_|y={cyvB`S`qD&cK^$bOY4gb?e75n2Y2XE+(gby_)5sJF!ab^Koe7qM;xeME!~&o@bXbONGmnMB zzA)W3>4{ON!1ag64j~3*To~m#Gy0ezvp^=#+-igB$M?LoX9gGIdP@pPZ$qX;LD;q_ z$dcS*vf2cl&1RaOZ>#nTBKp3;rJl<74O3WCctOeO7&j#RbQtC?`&k$gtlm8BncSm{ z@m-#A27tQzPXhWHp;BKUhT@6b0}5f>1zToB`b%?U5m6s7rt}EmY+SMS5Y|HOYW*K{ zwu3r!1Yy98`0r%2S8b~o}IT?Bi3SZt{upj-I8qvBx<8zk}FMj7a5ZNe!ctLN+ z*uC1b@G6Iq%wY>J8cZj6PK?p`%*uqdz9(h@4i<3UQT3${UfH z$nH~@(nl<{FK#+HUxTUa2BSSfU(btpUGuM5m{g% zHc7_b*nd-}$EO^z7@YUfK6Q|fdDbUa7@W5)@U*a0n*{X1We~{{#B!g9^`<|_>ejGZ zr*cqbz7^5|3;VQz(;cLQH`vo+Iz%$vnq)5USt!q&ElSPdH3gCq1pcPK>?<$82Po7v*4?e#*0EDAGX|EPGd!=XJ7 z@%4aMHT^s|aFE+_XP?LL)c83JpYGNW^2Y*Lwf5QJ4E8M1>5`aF4tPPI^a{R)`aQBG>sbHwO+}4{q*ItiM4M72$5Flc~2w&6d(Kxfw0+D{H}`;rNH4rA+d|YE8h# zEsIWvj?ES_Il8&7W;Q!_95L~BuA$F)UxW?2ph)2l{!f9p?_b-FRj5@d%=~1pxwN^T zCVR~#AH^RkV6Ko+6W>f}^7<}`5~*d7((C9sUG?$KDp3HJPQE5x*rKjtvRX>^UKW2tT|AxL5GFvvwN^q@JRO7iJchN=pHItXnUHC$MZaC&r0(8= zYdwy)Dm$&&7uPa{(cOx0`nHz@*H9oUOfb-F~XJQ3!8+ zZEMv2$o-VMVP;d<^Sbj^Bs?_keqeJmZFp?M>Vim(&o8I6p}vSu^UhTSs8C>1SxYtV zrr>0J^J$)7o{{8aEToqIOY}gktr86xk1n&q_~vsly;rYZ;r@vHw=oGW-zlfkaXcC2 z!jL{*(;n&l>eZh=rO}alLLW03%H}OmGINWecz^IP92Yj`v>tzS_SK#xtC^xt?|u2! zmkO$VaV+yWrp&kaSZI4k9lA|pcr;f?+9#5;GPkFcM(-|m32MlC?Xeg2@8_FMd||(3 z++$?C5;`xDXn>6(gVBDKy%@Ms)4B*O>buelxXHrNH1WQO|0+?kpYkn4LzHno>JM^V z6uHF2imy{owop9w^f4qaMU;0jeI*n-h0%Vk`UkIF00t`+#$ag_3TMOUIC8+Ur@KS?jP+vxEVIZA|AtAeP{pS>2H1ChZIc>7+k%1o5P&wE?93P|7u*PO zMuZXoE30qE^@q~wzpnbxc0gONVJS8|?oVV6+(mw@2NlpEnJz-IrpPF!#C77{@WBzcedLQJa=Y%!W$9Lo5mFmz!bIu&QFHgvO&|xh6$7EWA zoCPI~QJJ$kfm=ane+3nN8KI$dM-GU~JATBvPw)g}kwzx44G{$x9SQyESqvi@@qox$ zBFN0I!|2EeAmpkmyb}$;V=_koBCGsuPY`yFksd}z4lV9v0LK~wl)>>SY!d%6Yqebc z|CP;b?(!l$e^7T=?TpQQz?AqZ>!Ce#yr3>|;nPToaNaWQCvcv80Uz*%PMnd;oRF^V z;NUYreBlf2yc#qL1xm>7)ipGzEi5dC%o?h6o&@-hDMoOxZ3q#fJNw*^|Isb}v`+$9 z1x4U|V-p*5vG}%JX4zilxh58My(IpLuWX@i-5{&2NbVm z;M`_dG+fN1^$x3Un>1`MTq+Dx#gfQBNo$P%;=;_78oQDbY~mJNg=bX!3KPyvGcnun1*>iMZVgeXTTXe zkPrHxdVgPRyGNNVNBLKrPeAn=!28R7m4yK1ohq@{Bn-T13_#uSzd$(6@-IgV zQ~*bEUhJg*mHBHEY+)?;wn8!(cxagKNf-#=s*HNSh<8k2!# zus8hGR{=fc_cGCM#wOxo#d&eoidt>w$K!)LJ?bMM4u|8Y@c**X9erZpTuI+f_*Q{U z%m?^>U)}2p7)OJj5yn%5JghG=&ik-`SZhgkEJ~+egz5C56TXHf#e`L1JKNd2)e7oN zHB)1Q&G$Ayg_7V+MC*u}KsksEp(|UV+0VgYiZjU`sLAj!r=Abp1|T8@wnZG=+D$|- znhLRKuLHmJL9|*R8+ad)bOYycZvMEqfKW>9E02$=Fv<}!6u9i@?ma{ z4AJn(%mtv@E#-I-H`Rn7YNL!ipiLSuG4Eh2^^1U39#2RoFy*1ASfg3{%|tcL^>eG#G-t&&|+ar+UN<9`&I+XDUA>0hx2?&HO8HO8k+hv!0}{-RFD`UFtf?W z0rQuaonT?y)hoT7rr1O<&E!v1Md6CR^8#Q-qlIaI&om)0OkQx}!rcm|3unh!>{;Xp zi#*VEL&QV@vPT%=&M2K^9_6Pz4`O23xOi|u?@^2?Vn#Dr(_1;PKg_~L`)I$kK{&8Z z1@m>FuGb5R3N`q*S`u@15PTOnA}}FvtScY4L44F~VqWR6^ND<&@z=eJnLnzKhQC6F zS!QrLvtO6Fs{G6UDuzMn2|}zztc7Oc)+1!FA3t#Lg(N@VQv3~b&0Pg-CHvH}K*m>`fkyJ59BeflT9 zUv&}ZKL{ZZivx??vZV!-y|G@)hzQ8RfL4f2C8vr~5 zg43WIcuO1uU~}CZ4!DW_HC;922EJITf0_KhBnFzWa?xNZ8yRSJ5gwv84Hyzc0(>Ul z6!l<*)g+Mv!L~!56j1=g_K1Kn@g@3Utr>Pi9(cD=YRJWJXn_Rw|JN0%ZfbToI9v|c z{{rAWmGb3HX@#)@K!Fgfa;K*BRz}6MB@Y-K4Sc|rV_#%pU)XIvShp~|)L^Ae|40EB zt~*}k!itF+NMT|~?ewrZrQ;aDS?^VNHCScPKMYtHf>!WgIr3T-=wkcn*AiHn&oQt# zHEI1HeOzZ)6I<7X$fb(33rG!JkwCaqflvgb3WzA8bV2F;N)ZT#D$+tns&tWFG=NG6 z>AgsAkt)6S@67u>@89>|OeS+?a%OV&+H0@1?`@GmG9hnnXmVv9nr>zIThf?-DXL-u zCrT(KncUOWJwuZ#o^GUt?5|Wuara+B!FikgmymO7YiotmtXw0^-U;@(q$J_<=Q}`x z(_^M?9M6q~lOF3uH=bF4Yd8S93<-w`9u`SQdk%Z3P#+#B>bY%(@tH8>K;bSY&V)Qq*&r3t^;xFMGHc?!tp#xRY>eOxR) zBupv2IAX}?Fan!Gv3wb+zzHdyTE787gPX8Rg#i)xEqHO*j7rz~}twJvrOa zf507>5#ds&Qbq{2B?Ez18UMdiePDZ+8I1qE?uW@;XyVDiPF}x@JlJlx0}nywc;lss zS>7_S`!<*NAuC1+%;g+sKsL#008uxru&|JV zGkes)#pT5h`Ex`n4apc#S2gMIn72Nwetb&mv;!RP)p1Wvy|hM4jN!dn9@X#8c@}s` zM|%YkV+FmTjyCgsUV7%-9VZ3cSV_|3d}kZS+-TVPY0t^n5YW=N^S*l_<nB09(pL2f}=a*Dxo_?(v<0;ipQ7fp^B<|d*9A&|VkqY0l8{?H$I99*&Vd~!9uU38RUMIOvU#^sK z?w+nYwWm*e@LNvYQmXRmif?>e!XuXg`_ z5b9#M^3fAxeo?DlETEeLE0}bH-vMC@o(ASfm{;@RuwVXouKAJCBg9tiijUM`bvAyc zUXBB-^wDe@8UhKWF>ZY_nnK^sr)_hK^_sjztqrT3%VU;AT_hEl;Nws%M^sCU8a%qyqD{r@}KR^RI*$nBjWd>o?!>7E~aSO zq2)<$>c01!q|*#y@~dO)kWL1DQT9N#YDY*(t%!lEtoK1&7_#PJXJ=>J?a#SR^>U3G z%F}kGJtKGXH2_cqI&a6_^e&<3P&z8ALgiAUa*Z1q!a`V+Ejizu??pxoND~dQFYICX zoLKERG;?W|o7IT%9phT}=yX|cqOSg3dYI%n@YFuhzUxxEzcQdiOv(KBLVd1saJQdZ zUCi=)a{ZmvsTxIMDrN*QT~nZch5Xc#pZfEQBayC;-e{Isq$j$b(n zE`y#S&_@zEF7$3rKIMlb82VU2NAdv<8TUVNs>5#r;!gKBxC>`ZxI^G_62}AG8+k1u z3XxczEAR#C(@Sf#$lEO9)EG#_Ejy*E^+ zR=ki-pPdKi-1y>I34?Wi{-xLmO_6G)E5FhLDpTG7%^7!f7j^>{BNv#@8MZ`;UA@U%WAd$7?3!SraX1nG4WgUcbe5mYIW37qls!;LGSmO4YYClT`PGTk1p2yEUoQEyS&^V= zMd|m8LtAs~0_O%xbG=hN+WdXz+DKs#VHBh3$0+1IrlEYDSgUlj$&9B(`g2nOe5A}J zPYTZdhUO6bHJ-+`Z`yC9x=>N?i5o8*sDh^?rOr^vxf#=i57( z$Kk*9t^HVY0uMaaieh86<{mll9l<|d_(?O{CCoHnt|`uPW)B#|iKW@Run1B#pk95j zAsI;EVOx3LU8zkq$}!6B#VY)<9x))NQlzX>P)uf&bxmf9rN}7fL1QR>@+IA0ahy-(S@J99(J;;*`srqVQ`tzFm&>f(0@qKHG5&KGwU z<`>5pG(@)M{agJCvwe9h%h6->p-p{&+&P{ zzv%TG#QcXk=wemJID6* zUW4kQ-CpuB;y)it?}UAm^l&&mUm?ZQ!Q@C|7CnX#I96n^)hvxQBZNTyI_&0U#!ZRj zkBdveP9S)DIctH;1UMuI0^yXxn-C27-v<)5{>iTDi5PCXSR7le_+@nWJcS}w8~05H z7W5Vj$gm}XZ@%rI$Nhzw=U6oKgANHzlccIqUdr|%C30D}yeeggK4NhhM0?ITy3ERd zaS~Mzrjp(FkUGILo%U=I7{{4oN*;+mm0nLqW4Q|3x7kEy_md;7Ptic3EDhqzs~Td| zS@<!GOg0cbh#lKNDxMA$X~ejPa} zRZ!q|9WM&|?CS7h6y7uJn>kym2BgPiUaDhjEMlG?Qzh1i6^s~!%9UZ|bVO}+Tmi37 zyxT=IY;(T3WD70UI~W?+hJ7KwrG2wh^EcIX^+_T31S){qgCgG=zGW&QR8i}bJ@bE% zj?%4ty`D!ArO!Jy62?bDxHZkmUQ?136tJnP_1{G%iAh#>XpD@kbbg!Ki zrs45TQQa{Jz*%`}((L1$ra6Y$WG^D&~%52dP8aX5ZxP)u+8BVSk8Rz|`ss}3a%wu`hHSHgC^|4}_R2t3*L_M!K#R7kC zjVs3TIa*E6#83{7lWnpZYr`MCQNra}x@9fqu?q}E%4=_CTu4r*S6KbZis2K|92do6 zgoR(ow9|y7tx+e;*DK?GpEqx9#o3iy7`!ty)Dy6qc_y$GK5=t@YpO<1(%g^FewK+V zNUmj{D1|guzFzv8s2}+vZwEQZ+7;U0k}#I9vOeR#NAg%>{8`a(U@`J%1WT;Z*Sfr~ zDz+?TOcM5=HWyuzQ72J;f2IDK-l!U~3i@OaXJ(Z!iOmdn{dcw7c5{p+d^T2FPWR~C zfVTZ_KMAd-1s=!HrGUI`(zJkf0&?$^zhj0+NAiq#zG8*9DpmhhRriQ=T7-hlh`I;rEZ62%2Ww0;|%UgnW zURn#WeFmQ6$toOn3>;J<^aFP+hQ{0FXD`$vcX^9yob#$V$kv4hT3)_?Z~h9+`4CTn z$VRtF`fQ+RQC`eFnuFy8+Mb!ezIVWGmoV;dP`7)gw6lK|nO=x~mF&>sOYPN_a$C%t zl99aCYoB>HtPYWd)}>z9d5{?N&(-n3ImK`5;~l0fIGD?6r1mJZa>V~u)*H|6juY_4arhG_9XIP6O&RNb33U^cVM8ih=w+A=ZJ`BU1bzBPr;F(g^P=Q4N^}ubpeO7_t!(U#SNGz6`tn$;an}fK2FF1&>k6>m)vTu<%!r zVSaZdhLk$3phII`q{O|!f4>iP1hE{e_wzHJuMQESF1t-&v*{J~neh*n%S;&ufRj1w zGU*?S8#bru2Af>iCom!gJMt)?8T?q~xwGK>FYR^VV;vUivL(USHqF75wZbjZAA~u} zjo(JUU>WuvhAD6?W;EqCA)YfqO~DeZ;iVMBaf$IEa|7>F@6>&hg6R@v9guk>|1dyf z;n$p8uB?rKEH4YRf&XD zb%!@)-0fYlJ=E^d=^8@inaA8Y-ZYpPu>)w z!R;iYp2^WJ)A%MAEB6jZRK3d^{P$S-{9^AHwK%POrc^Z5zbXfY%#A-dBWv!L&JDQHFwZur>2C9pN21Yrp^8s01pO? zJLq)~En8#~N=YLx5%PBI!xFXYRRS0Gz(%;|CDDTDZuSe+E5VW{p5JD+tofSJk)dJN z7Q|^J?(bJ==Z!_K!jk{Xqo;HtZG;ML%?}1ed{b?1yLgm06mB8SwmNmBm$;e!>DzD7 zH$0A2y?^q%kgOPeuKrC1Gl9MJMnK1atoL>ie-D0|it8sPw~Y z#t!+lc1ZwLa9)#WLJO{H%?m~@Ch87=D3(hoI>{dmYC`lyF`ydD;@^5fjThVD;F5dG zCU4Xsf+-$?YP7<@)(Rk00EDfkG?AeY$3u&`f>)osaep$aCl56pRll#TT%u^^|9|*l B`lSE> literal 0 HcmV?d00001 From 3a7d6545e513b8b204799fd7345cad88c1a43b21 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Tue, 5 Feb 2019 09:00:23 -0500 Subject: [PATCH 19/34] Update README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 3977187..82096fd 100644 --- a/README.md +++ b/README.md @@ -77,11 +77,14 @@ Bumper *can* be used with the official app, but with limitations. Your phone nee - Configure your DNS server as described above in the [DNS](#dns) section. ### Import the Bumper CA Cert - E-mail yourself the Bumper CA cert (located at `./certs/CA/cacert.pem`) + ![Example of emailing CA cert](docs/images/emailcert.png) + - Import the cert as a CA, and trust it - Instructions here are different for iPhone vs Android ##### Importing the CA Cert on iOS - Open the e-mail on your iOS device, and click the attached cert + ![Example of email on iOS device](docs/images/ios_email_cert.png) From 3d572f61f3aac1f1329501d8ef1147cdd184f4fc Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Tue, 5 Feb 2019 09:15:37 -0500 Subject: [PATCH 20/34] doc update doc update --- README.md | 23 ++++++++++++++++++++++- docs/images/ios_cert_trust.png | Bin 0 -> 26393 bytes docs/images/ios_cert_trust_continue.png | Bin 0 -> 44399 bytes docs/images/ios_cert_warning_install.png | Bin 0 -> 39366 bytes docs/images/ios_install_profile.png | Bin 0 -> 24152 bytes 5 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 docs/images/ios_cert_trust.png create mode 100644 docs/images/ios_cert_trust_continue.png create mode 100644 docs/images/ios_cert_warning_install.png create mode 100644 docs/images/ios_install_profile.png diff --git a/README.md b/README.md index 82096fd..452893b 100644 --- a/README.md +++ b/README.md @@ -83,10 +83,31 @@ Bumper *can* be used with the official app, but with limitations. Your phone nee - Import the cert as a CA, and trust it - Instructions here are different for iPhone vs Android ##### Importing the CA Cert on iOS -- Open the e-mail on your iOS device, and click the attached cert + +1. Open the e-mail on your iOS device, and click the attached cert ![Example of email on iOS device](docs/images/ios_email_cert.png) +2. Install the profile by clicking "Install", and entering your pass code if prompted + +![Example of install profile on iOS device](docs/images/ios_install_profile.png) + +3. Accept the certificate warning by clicking "Install" again + +![Example of cert warning on iOS device](docs/images/ios_cert_warning_install.png) + +4. Click "Done" to exit the profile installation +5. Go to Settings > General > About +6. Scroll to the bottom and click "Certificate Trust Settings" +7. Enable Full Trust for the Bumper CA Cert, by moving the slider to the right + +![Example of enable trust cert on iOS device](docs/images/ios_cert_trust.png) + +![Example of enable trust cert on iOS device 2](docs/images/ios_cert_trust_continue.png) + +8. Click continue when prompted +9. That's it, you can now [Use the app](#use-the-app) + ### Use the app diff --git a/docs/images/ios_cert_trust.png b/docs/images/ios_cert_trust.png new file mode 100644 index 0000000000000000000000000000000000000000..9b1b45dc49dcfc7a85a853db29a295f339e84124 GIT binary patch literal 26393 zcmdRWcQ~9+|L!7sO(c2^A&3%!V69F>4MB9G_vpL2AR>B?-g{Y{uzI5R-b?h}%c|$` zecy9_?{&(5=Z|wd7uT~h^O@(F&&UU z@Rv5?JvxwJXdxk?Y$YKhVP|dUp!&to$VAe@@r$(~OqmA+dKqbCV4x!V>SdRqp@BhH zKN|~g^ z8KIWGK#R#%kMS|zxcS@W91YO49WezKJ{l(7KOEPk@L>?}mHmb}kN4mGK$o~w` z{Z2gK;hNgN2Yzh*e@=Q~d})s;jqN_0a6oOo z0Hr}7FjNq@wJ~urq=VX6+d2wDg`fXZLJ+urNalP__fHWgE8*v#6qM;C>>Nz!csbs1 za6O0M(9zL>9gIx{RV1bVZ4P`Aes1pM^hJ=9)790L!tqOJw{>LrS0(?w9!V2NBL|BwP8N2ybPx3!eztRV5`O;t;X(i7=U?w>0=4+B zC)qmwyIDX3IUjO3UvqGA{_o73EKL6oW)C_4GW(}p|9Twwp)x^b3#f^;rlf_9iLE2> zY7qXn{NR5c^S|W$*Ngs#<){C$e9ira`#(+pBkMm*A6`XJ#l+Fh+WEm1YPJ?m5Wwtz zm;66lDLGh}0KR=NgS`H?$$zH(yF8fl!M*?C?tk^=KdC@xLU6#G|6^c4aO7f%us|SD zkgTNGM<~i(3Z@U$pYjVUrMzTEX#q3FD8{c{!^TuZWL#5Apqf^`*7Hhy1ST0JS>zHU#x2 z)iS-vHt>D8UR8-Il5A6bBp|^8E-u_hAUrhsPjsK?tk{V_TviQ`mo%@4UPUCmV|;Im zuKqYyfm^d`?YG2;OLq<-)mQYd=syg7GY2l9#DP}(q-wcyb;^Gl6~AWu@C=kI_}lYr z8y)u%E)@jVc)+!Vc~6lVr?ZeO)2zpdj^qV_ul?2gf{C1G?}Il%H1BGZA0Bk5Z;XOQ z>vLd*+Whxtp(LnIsTD475!pX0rCv}?9s_9LKD4`Gs*e4zbVW);O&yK#(2 zeMqHJ;@t}txyhGwB*VnNt!|jy!u-*O-TYahWFwG0P#E+WY$sF+R!r)jv`x$RCMbEa z6f{qWc_6`NvGfi7M>U4|l7_VL+PCbRisml16XKDEuy3t7)h8$FC;;jl@X zSs4L?luV+xWnQr~0%&Q<=}s1&g61;~u5fArfj?doFG-oyeos!m?TKZ}Lt;-iy2mTL zy^QD7*L!;~_e<5m(NTe*R4!^LFzfQzYNPOm!}Sr~2R+%<9(XC5mso|g%}%<~G{%*% z`#DvWE%Jz&aQ^L*ey%(@i&|mK?o4&>)%@9j(n7?=nRa?tB{$*C*a_MV$&b@Q_T%5x z*aiB!%#5NPmjmxoH>3pBUm5Gw^RYO_y?CMVLLj>-ikkskTiuPPNibQW1j4vYxt!i49bnlPEJ-)$q<)Hs;g>!R5!MJX>Sg9 zH04&it)#U0o=%r*Gj=}a$f<-AD<-fx^ovAQ$WG$QSro0tt8qH?Rrw>hv`(CEBamWV z>p$r^z3K!q7(`MLUgEPqe|<9E+J2WSpL!%1_;;rD^FXqILoa*nyDVw^Y}x1zhaMK@ z?y`%8*}>j-Oef-u;P01=%ZWz%KQOpV+Pt{jVv2RjM=qDtAjnnX<4%#g55g_;e8YNP z$QJ^4mqu#~?gw$9l>9w%oQ;g$m+kl2jS*0xg>O65^D@g-`i>apq^~~F(R!X{>`s-p zcQYW#@DkG<2fMJl+R?m@yS-&^uf|tL`*qPn9+luq(!JHlx>NAr~UiE`i(%kj-O0oGGxtyh0w=?p9j7f097SsnM| ztGP~iBRE;k)c1EB`|NSCE*4z=@On`E>;~`V35M6Kv)85vQYDkqdAEBJlWFbTRcfJ; zh2FD1$XboASVzpbWiz`^kHw+WmAFC%<*Cw#l!Pi~*;@yk7*Jv;ZsTHslg`TgaIQ3^ zq+eW5;iSyl62;(~qv`BZb0&v{AZ*X)8=Q;rk2s_;(Bks$Go^yF)rv?2w|3;3y>6W| zHDZ0yFuTwQ=Q!;UN4d$K=K>X;*XJ>lj{`d8j^(I=ZIarS)ZgpJy1`yDU=-nKZUO3aET>U9p>Ug zBO<8FW-DF8A;~XC1E<6GSiN7s?vSLbg+*6AS7(%GO+R4qKH6I&CNYz#W4{MK=)p4h z5W_23;gBfg$%e)3+UjKUUG>KjEh(?-*+uMBjU+ys)!)QQZ^+Zna!@B}6Xpq44RKGU z)K6QIUa9?XyS64zSINOSzCF-q2qxu>d(7}H2|pi=9s72_={o$_`~JfF_-e=QU8Si) zzfseEz13PlUaC4ZoG+r2!71WcnMC056=(gX_Jr#_aB8n1m^mv*!ypUpE(xC1{NzT>c~!luFv zC0O%az}wAB^LqacpE6gkRZ?KT&gdBy{NEXwKt=UFDa9NrHf5F8F*i z>lp5RE2mzf^Fkq!FVb?nkfp1*_NIcu5{2gWN|^m@n>WJy{(2F|>wvOW6B|zUx(zHl@mQctvp0^N9pP>?Lf~+0K3r-??(JO zSIkG&oe^G0mqa^A$DF5NoZo=p~zewtjh zoGfh&s4mf}u7D(S86s|O?FhSrIh%}qrZS~)dpond?r$%grX8tV7PYC#^Q`s)_I z`}r%_%~5A{BWVdI%m9y^TT;+<-@2Y|aPjV3gWX%Ku_x4&G>)+hK6_OJ=9wTg>ronb2{uqN7_+UCv915&u2sFf3*vzV* zF{VbP-QSS%9gSD=DPd|aI1V;)TTM_E;JM zK$3b@yLdz?uk(;WIpPp>tyNj<|L9675>WSqlyZ8G?An0mxv>FS&Td z3J;_MT2%lpDaBIBr}8!gFVlkX#T>iF`E4l>FiztzI%i)539lKUzXl8U$Zq1B+s@X^ zBlHjo1c+v8A=d;N*Ev|j*40Qa=li4@o06M#Z7(L(Tt(iY<%|mJX>R(M#he<=YPZ-b zyTu9UoYSb1@r}RzNTw8zX02_>cnXheO?S^sk$Ya(S9za~wRnAc*L=@KuWM>g=qo;- z>wVYe5<}z*9O;}jKSD^vLZkcpk{RdKImf}+BKUq0-Mv}SU?L68T}MBq0>PDK*4OQ* zoWxp-tgrhxJGi@NMf&rW&O>Lw+*fkixcxo>vVuE$)RE-%QVD#-xfjoklNfCC3VgLG zays{+_4x5#8bt7$E#ED(LLy_MTMC`cY>n!{xfXJn5FW38^Oxi#uKZv!^6!-LXL4)E z{z1rU9&zGKB%!CQiSm8nQnq4Z$)t@M;T|78^P*adfTr(m5G`Y0)=chFp3$2WkROya z2bi`wF88alZ+3V6A!kn<)`qWF85-@Tl_zy#plK5p*$XR5&9Mxz&hT`laR1`j)EGk? zYmJoHh_vQ{g_R3p2l>GK_V7Zsk>xbmX%C&Y%_56@O1o<3@h-XDAuv5PB~wn)*YzM> z;tDC@R=lIXnQ@zmic=*S*h(jEXI=);X<(lpLt$UOA4X!|-kc0>(TS+hxNX`Avh;Sp z?xZSGI%C?V_hh0Lbm9W0(OX62{DHR}jd*FM&_d7swfB7lr5!@n?Hs%y&VCFcL!=4q z*NuXCnf8>tuZVH)e;y962W)n9X?M784!MpCSu>LAdz9at!Kv|e1f91Tc})AFaut$T zePY$`gvXQIWOEf$GU=~1aSC@HMbup^+^Z2*{qVTjVmy0C6J@``l)pRQpX)OxL3pdy zxUiB*Bsk8nd4TO+r|`*+q4>o-Fi1$mKL@K7oeHxmKtDp_z>JQ5DP)Fy3fADBPGcm?BE=VWO%4>9kDMviP9|5LkiBDxzQETJ@9Y05}#n~RV8FH@g zNt|p5?Gl=~U^`pecH90#t)?WbpgSTShf)2+!7?`64HmhPx55QkH;!MpCYmd#*q@3W z<0T#U??W{G2-f{;_37HUwwkihjLU#m@B^~pLr#w-8d{EIg{C&r&v7*JBFg$jd< z3p5V&)_>W|R85P9I7>X)ad1DEq#@an^IghBQcx( zIp9ZqE?oDpDJW2A4RD>rD_bFx7+4h#f#BV|R zB<772*VJ!K_`H&fpKBVHv`J@}aNLreM!KbE>+Bh{7aK4C%B_a*3{>42B@1{n2%=w!+-+N?mqh$r@oxi**6ui~+)qvQ#N^$@?+J=b$*#}L zu5T5|3D-z(Iw-5Zmso7~cb~vZX;NZ-1(!M9bC0!k!BeqYHbvuLBGQ|hDmUV-N-j<9 zfYrS8nzQe;dU+y;M#D5l?K)=?J;h7~`gTk8(bJ$jZVgt-V8*#2FW}ZNFsGp$p;ZOV zP-DqSC3Qlu`t=!gghWC-6(^kGfosK;10y&6$H27R0hMNtHGQofopybWuDsL5F@6Mu z$DzJ9=(>}iqZ+_NXio!PY@*E8>G@~PZJ00GE6ijG8G{aa22Z24?Hc=CjMHH27VZ%q zM}M5OcOC&d&dA}`l=?2Tr7}q1#$2_P8G5@SMryI&`OZZMY;7pjZF>vj+Z0N}?Lp(c z*-j0J;G8qdiXRFmIPN-tjfFE$&ZAHZ+$07*|9~R*{Lmmez#;B!BA<fNis*@An#OSj9*qx@MN% zGOk>eO`tQn6nKKFQ6{`SmaN66J61!_0xng?hxzmM2h+ZvhK4(igTlUAy9 zG1E?^pI(DDBUw15s8PFKA6bwd3SMpQTejP;+mEatF1HusnCN@FZjU3<+V1%6JgHkm z$sDdtp7Gl4`3~xtawI-7GQiKnCr2Sky~g)zEqeNLd8Wv1+kmi@KVp5P%0hJ((aJ+D z;NahlA>#B20Sywai%7`&ob#jY>g6pyJ}mV{u)-wf!0@$M9NwYe;^<`dVVTU9GhartgE%_KrLJ zWWz^3l?xK=U-Bnu-pmE`{mW=#7;lMvBGwMPy+4&Uo>S0U|GNAe?4%1%LG882aZ}o2 zWPcIARm7^>;516zGvm;X&!11KraHNDm$@LScETFrzA->;>R5_BZ#hw%gL@Xw+Kju_ zDbm!9z-lvRYEt5!$eY^w$X;Md*XcH_=P8)%GP6~m?j71=AEJ2NYcuNS%to@{e)_g! zQ&W}U_h*K{suW&Kg;y=nQ!im#Io>;-R`ar6Dw8<_3O^z+8-kNdK6&xn`s#Cb;h=KUsu}9JE}X2YutL1PheFX`_#I$`3_U1#7zx( zdvMn&KEqaeS+if~QhhX;H&}3dcg?^XQ*0^PA%P9U`at={JYA@db31XxPChWn7BOr+ zh`WYJR4xAu)0W#?Y)*4CGI?g;ZNLpo5~S-X!<~64ZizM=Y9(6JF1;{?%_r65J86h- zmGb=72$AnfIx;h^I;WV}G+Ws9-(VN>7gG(9U46kjk{Hl{kY_Yx@j4FWdwM`jnqql? zyVi8QKS_NhovSKtf>U5j@@~_uTTDdy@boemA6zPfu@@}_8R~ep`OS-y-pu@MUlyV#OEL> zSB`kFW1oyP#=eVot8dR2egCmWj5@k}G`h`AA0Afko7phVI~HABfVtxb4cXych3NGX zAoY`eTMtS)68gxVAUC4>RLvI<0bk__XNy6MN6$fpLBz~cifnLAE??}}?hW3jEzuh0 zSe5TUxbUvNRN>y4&51qp2Gg%c6qrt9MJhQJiZdh_OqHiV9=-dEW4gK z7ORF6VkY^r9G~+&q0Lf9_oCnufu-gdmn@;5jZ<4M!tYuMpbj9<$SW>^{;%1d`vPf zdJi8}Orq1W3E!A20>Lpq%nJ^`c-2LidlCon1+ZmeJpL8lE81ZFx2%+8<3KPoX@fG_ z0}??fkj_AFTc+-JJh+nBxg;m|OjG zmJJ@^rLdkevKko33;D{pTyqjgYV)K=0vEs}eO#%xHP-O=xs3v5XU@sD;@L~{L+ zdtT6_;fR!X31~@1G)Z&NSUCl@=KmBv=!po0k|XCfG-w8ccgO|-?FJC+|i>4Hf7zI{jqT;}-G&eSSuvQ=q^q92Nb3y{b(?QT)D3x1LX46ZowWjd#mZB^*-h-L zfiJ@>D)jp+k)>&%UwQ`Jg)4ZG2QeJg73$#SAmu}l0I@mxAzLc7M9FToPhdaPGaKZg zzxV0oV~{j;9P<~;ZYE9!oqs5l7X-xqfe%&UUR~1|-&#>0E}15DR1gi)^C=@9 zK;OKTC&25U)XjXg)<^PPP!f1q>&9 znz?8K$rT%7&kwy~1{DQVS%2+!+=@DF;|ubcp1&8nHt;R35e2mr@86|+ zl!>}%(}4_JF1&{zpXiDn$g`pbk(acuBIbcU`T74t_56|VfHr?Ue;8MqR=#s7LawGI z$6`khG~sTI=;zKb>h4`Wv4{43qSI!NcxTz8zUH^^P#o|{8FtkF{YD-(*Il=>CEx!^ zA(a$){whLSh2?y2R)a2BQc?W!LhXg|QL_yORo(eD{5{6ZT30vnxbn}M&_`}+R)~JA zjku6ht?0T0N97=44Oow}l0fB8?=ROxXw$|A#UJtZ_s;PQ5+6nl@8yU>qAc<=aWtP? z!7FZ|+DAL`NL;qwZ1!ERgM~)Z*_)P{GPjJ~=1Ejc3L#(wS6e0P)0m_VCd3GsHO}Fj zAm1v(AZjBv>l)GvzEGNc1YOh7%@dTR{rQHq6xW4F0AG@|wJrTv{OQA~U2uR0BBgRb zP_9tD#B%aYk7WPTME5Ale zb(QBRxGJWec7&<+O{CR$;G{jWl_zY z)&aazU1d4WtxMWwF`Cdn<3QvDdCFaha$mp#^peSHX(z{n~J-txtE5xfW3 zipG{6oSFMM45VV(#dr5oz5QG9XX}OvuNi*(^2wc&%4%(pGnMB^(k- z2)TTz3o+`DhwY6|uH|P@$XTH`wb~KzPJ7NZUMze`c)P5+l`g8&DDAv21S%um6_DGvMGcVB&GqzdE_ESAq_H5aK^~4-^vR+5WZkKXukIY_Zp26KI3vqgB#jpbeb;&h3fCxP`l|m|_ zAUkr|cx!v5hw>V^ly+~r@+)DPw)-{tH#l;V{W=dT)E& zDrR!=g3bFRl)gKXK}Pe}PQ5VS#IPF5LeISEK*P(8C+fRs0(u^Ea+Q1peTU=YavmNCP||7Y^L>sVK@~(XS5OOR$)vt_V|Spn z+SneGKhu$MeZ@hn!-cSGFS%K=#YNqHC#^T)207>*Cvvk)=l~Gl=H}(YsRou`iZ*KM zj(gZj6u6JRvUs0Qo2|_`4)E4b>bWZbq&FL9<7retI~;NRV{Jv@{<2dfuHm3*HjWT0 z&vk#!(BV11!}Ip9=(r(zC~A7%x6Tbra1FTL@sc0m56H_`Z}cjRgJQSZWdIa7?K=dt z8pNC$!K9GbwGu93Yf`^tuyJT_{HaBlCN3J-tWvOO*T`EV^J2pC%!@g-s@?%(xNyy6 zbj|-4F3Brysj&S8g-)W!G8WoVBfgeMsQ%7GNv##+G$Y^9F!19rQQ}yGt5S-q@C%vQ zBNOItU2OruIi7(o(tfU*1u9Y&Q~AC`=TRKZe^o4Iii6w|AJZtO)yS^(YDDbV)lIZn zZv@3}JkAtU(7MRK?Xj?0m_t|h?*99tqe16e>B)xFnk^3l$F|IeA#M>Z8GSGTzF2>j zLp?y4GXVr&?8l|bVqkm5czwEMq!%`d0D8O|Ai()uuDck*8EK(QD4g%NuRc56TpqdH zpN=XqUtb*9O+rmm5x2lR(Si`wC6!}MX9QFXON=fPL1|-L0x5Z-kpR-~ozg@rsaW+xnS^-}lFkM@Qxs5tehXkXUq!kWG2A@|OBQTzm@U8zJMfx0swz38CSjyN1B z)PSdsDe^&Pyk*hfv8`kQt5i2Y_|jdXcd(9Bskb6WDvMe}`1Zo4|E z9yDWKDf#X2jMD)XJlf@EVNFqkfK(;~-NV{us_cVsQ(!byE_=CMhDip`JFWNhQoGzk z>bP7c?W3Jt_}WRi(^kc5GmW>Pg2ci#gACA8cL?(CIx9L{6uIump?TUR0=`h$O;ZPcQDPnlVA~L}&RZcPS&GfN{2TWKuTK?{glaP&|xaK&L zLHY&nZjDtQy?Ca=6TOmeEiR2_N^dSsM#r&!Tz>dWSjN@zC#yAS7(10o)hHYOOBuGe zRBzN7mYL3Y&~#mT`0?#YA5V@05X_w+r%43p8X&!$n2|7FcS2 zIbQG8QYu}D>XMKYHs7Y$?q8`!m~B2*Ox z&{gF2W~zz7!V{dljC|zN2K2);G@#VY{0~{yqn$XX60L&>Wpu5|!Emor28*%$NL{wK zy;7vQj4_czBH0XssC~~mNc0lFWo-|qFjZTQhddhRxQ^N|a`gR+5LkP&&{>R9s`KkT z1?~8|!YvGwl`M*$8zipv$rG(KhAru(4ytkQ0XEj)8P_JLmzOZ~P+LV+xSvB;keuaA zhm`N#@Y*SlVm1EHH=Ld(w)Zi$OiH?X1;Hwo#fhcoREy18j}HjgK&BLUXeR5!t#$2; zOiiW1TYtAPF^mD_NUTY3oTetz7cJB6c35F83&6rQ1^Cw6GB@Af9++GnE^l2eh~d55 zE|ZX-Y2ZYih-25@{(NBCi?+M@>Iq$su+N`$oDAU`?4pzo*flaLm@~3=(Q9^_cstd; zgWN(7MDFl-2F;p(?u%bQ&S9Zyo}%EXsF{!E)_e`&Ldo&XO$ZSoz{P^(>${ znArM~#BKdPUoM|eVVc{do3GK6tA4(i&V%X$i2>EH9q0+}-MqU~CH|u`A9+Rb)sk(4 z2#>xa15VW%CpU;D{o0k1`+{v?(`_lhtAC`;W|oUDO?eOjR@k3*Gp{6rUBb3wICQn% zZ^YHCk>NB7cc=&8JPXmwiwMyQcYXcC{NKhCYeJRpulvfBL zJuj)u;2hOS+7%@OB$Y}i9bSS+QgA7=him~I9wmUKKceG&g_@Y^1z?;uxVj0J&S%uT;km zRnJ7W37^<+MVZup)1BNF6B2WU@o$seEW|F>fFVmO;6-I!!6FT)_~1(MoU_GLdTPn* zsIB_LE5&)YNRG2A{0X`Xw#n=IGfQnf9gW_XZ89{1&oWfJvawnpg*FU96~pFM(O89* zHd*d(HbnaU+KP|>jh(qwKW!^Z-e7ye*ze-x5ysv@*eCj_Gur_ zU>CsK+j2jyLDj40uwXnQNUtQP@+mIu?M5fnF`#6E{Dj(|mq}}^KH4l&D>CHTT6#=G3m7uzU_?cW;VgwtW_gPq()?eXC+K|V5JWHw1OLazH!uHTx>41%-7 zM$RDQ+?Ujz_$8IEwCnpWpI6GfgA;m4sF!M$^a_Wn)W3>aW6`B3^g^tkEHt#U&&lM5 zLvAF)g!ai+6jE5Pyt4McY75kR-5gDEZdv}8x_OzC`iJ(%Co>U;gI9Q+l8~#-EE}0; z&nI#ZQ?b}(hbnSA16af_a`CprO9O6KlMYM_Qwc12SO)u$62sCw z8gQavg>n_qw6x4;HULf$8Q)!7TI-FY%(tGc(Vemh$TjI*=k~n9a~IIm7m5U!f>DIf z1`0l1XOt3d&RXil>1p=CQD`hVk16@!=-?&;KqhlbWh@&Il)V`%)>Ng`=o_AKw(P!U z5E&wb2nek3p@-6(M*FPzFn zKr`3bt1Niu`um96oCm-n2XdRxuuQVsLY~II80ei1+DNc25EF+X`nFDbT?IuX+DC`m zub@pQK^xB|_FG2;7Qfsb)QTbo$xoUP#Dkpja+_q{dT=hSo@6^y;Xx7Q+qRw+@bT!H zD8PL~l&9&@Tk6u^^a-;NK#su8E)@F$V4V=9t3GZcs_QR$M>Y8e2!f9^cDOY|ov`wV z({T(8~Wf18~x;WLtlQc&B5nGj_Z=`?-D$4G96Je!fRKM%O0zrTCVw3pC9mZ zhA&<_BNR>bX*U;kNTlQSXZ1L}Twuu%y$)wn*v$ewRDl4xt8G?0=S}!pCoG=Fal+1YO1!kWu^_WLrcHv?hJ) zeNNB+fCqhA>Z%5?Fos&Yuf`9EP~bFxm=RX`q5&uw0E12fpTg&wu%rF~Gfe@M4cv!8 zu42Z3mxCJ&5rXK8=}vQqf1$_kmD0`NMY};dxFI;eph!A+A(*;hHaNH%4fCdRyhn*^vLSAtR8Gw#@reKG^@C_7|6n?&m5DPl}j?c8CrCOL-rI?%=w2a~vOnvGbs3 z2Y^UFbcfsUqe`*qbfbSEm2;@|*lnmr?;acqfNW;IhZOSXXRx8S4<0uGU`WH4{}`4$ zAeA7##q+;55fmdJv`n>UHHu%JO0X?=8_Iu~gNdYBi z@X3$SHJO#Qpb*Hnz<7IVIOSko$WzX6hfJls`S9i$kxI<`gDSm6nCW_FFALjuj=w?H zdJ==><(p@pbM&`2Zf3$RO)VqL{Gcf}Gv8}TO9`!ge_uH#HCykYP0FdRuC8KX1`$lx z+NJHzH)y<%Wi{D&lcBd8pI5RT^JDu%C-Iv@dp-B_UD?GAMhD}Arbb0ev(5ah5;om8 z<@v-)F{<$eD2?&%l#0PLcw7YAbQ5S0c zb>u2NE?Bloh8!$=B-j-w+|8q_DoVh9Rv>fi^|y&GCBxCn(v9-ERKz4bNFKXAUepT&bveMPf-Nz{4V&o-DOR;Y z;}nJYz1iAX=U)zMBWMwBs}lOdsWd6KX6K|U4+Ra9D z6)GGz6nl*-G*pr4vw$M$D2wA_vn^ecW}B{A>D|od%n`ku_p!@(*SAmn9OmntCz#O2 zF;Hop&a`KQEj=vr3rnolXSzN9r~9KO3c+c6n0R5RVdz^7mh zrz(%2sDzE8xo{xao3C`IVcdqiFu_FiVMLgCcdvfZtjI z;*#CBfpM&YDUjZ78p1HMA_2eZ1dr@IIbHm$85trtgrZ@IyA5se7r_POAYZP>DUCElW)K3X=YS zP9X78rz_2RAH>-5ci6;C?*LN5hUoQonNSKFzn{l|Ch!BG>S2v#RgV&OzxdfV=6%q3 zkqyMbe6Ky13~@0yTLSt3wY_@1VQzby6s`^Tt50R3fQyYMuKxye8Ux7jNUj295zm(; z(Mps6dB~|~4To-hPt=RxaYgt~2vE_p?Hb#8X(pvR9n=0qKoNc%Gz`~l=zyYO_z)2X zW1j;!%B-;t)o+b;x-X)F0v>|aXg5!Utp!@`|d2GSf$+MYy=On~o8*ZBf=un&xgiuToJZ{CL z#i}1B1=_$vS!Fr@UOJ45-l)SPlY8%J2c8FcaHZ*>impiQ-6hb3J;V16z!2vHW|9Tn zT%|M)Gz@~W^Q|OcEL$vAtIdWF+gIypRa=@E+yna3*|)~YA>=$AyC_^MzGzk6H!HL@ zH>f%xf!M?yxQ^=*)I35i)|0QB-UF%vR(R7@;n}mR{sbQFo;WtPP)gpK^0T8(+F3Gk zUdxzJY#)+_%THBeL=b`*)E8Q4jAJX^&H#M7Lua1h$ARMveWFpKqK0A;Q)MwLt+n<0 zvuM;$NqI#O7SU`0+7meTU_e-3w`>=HMr8Z_ic>^5A0M_80>XM>JcE2InV#$C@>9PY z1Cu?DS=>%1u&Z!M-@6RQjWC@Cb^HIZLI9GRJ=wiz^UxY4Z6wo)=?Qx`e{`!^sua@U zxWvx07h~mQu$c}}T;~es5VLLoLWQ;8fO?A9JuN@INdtorHp=weCrmy8+m4-|Uj_)b zCAQ`pYN@=%38!nEGvWE95vU zU!MU3f4UA;l+0*TydfmTuzf+b%$CJ0*`8_M<_eDzB5!N%;)gN^K(_RqRE||)m~pt7 zLzJ*c{PX}?&=2aceU~b#(G{|tU|<&S2DF!EMzQbwpKjHwx}ELjoGGcM3R&YtDbd<@ zl){_u>QA>uWx^m==0%tw)DBXeFTTSdC-7U8Apn9(vJt8}pu;8M;3`Ah9J75qpJ$Pw zqX)jy1+b=5=9{CdjxcIAkp`rhN{Qu*wQ(unWRsl{Hn})!D*F2>+`c83S`XxLY#ZpW ztPBm>0nqS?yn8HnoOllE62svHyw4c=q|`)vGc`O)7~v7zw{|qYD@@po2j&F$%8%au z%yz1}mzdALRl&ewQ!lP@vl`b9>e+p&%cd%hLqNC}lMuHj^2LMdF<tS1>i1DK)-H z{WB9O<`oYVg(%G*#8=bZ&;BCEQEnE^r+z^_F?@~u+pO_&fr!nvJX&4lAVj+{T8r;7 zH5mwVrkn6xw}jbb8ei_by_*wXCq4!biP#k z`47(4aATnG&C+nO1<$Y3D7pRx{}rfWZ#;a&C?OO-es(Q%=g~nO^{?E;zd@KUQW=(H zUN%b5)`v(u5xlOz940unciNfg2R+sU-=K)nw7tS5tFt`QZE)4pX7cl;m3uTy_34*G z!1k}X^{0YQGL{%as9vjxIYn};e2fLOh@BmA*iRroOnjffe5$%u>F^l*XX@;u>n~IG zSl<0q0nTG>jThwshdzEIh?pMxMW75sN8~Y}`IjC4Gzu%n#`qLM$v+_Ies_Z$X_UG6 z==Dg-hvxp>p%Lgw+fo9TG8XpX&!1fjG-wWoORXe=TvHtNo1dfo9Kvo??~v$Gc|W

*&N@M=WEVjs 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 literal 0 HcmV?d00001 diff --git a/docs/images/ios_cert_trust_continue.png b/docs/images/ios_cert_trust_continue.png new file mode 100644 index 0000000000000000000000000000000000000000..5dca5b7c5a22c683fb4dff5b85b2698b347e219a GIT binary patch literal 44399 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

b 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 N002ovPDHLkV1fiRg9iWr literal 0 HcmV?d00001 diff --git a/docs/images/ios_cert_warning_install.png b/docs/images/ios_cert_warning_install.png new file mode 100644 index 0000000000000000000000000000000000000000..ceb725cc6d34bd0bfc7d092ee12c37ae62167ec6 GIT binary patch literal 39366 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 EAKjHHtN;K2 literal 0 HcmV?d00001 From 13c2e07dd7e272589a180e6052855fa3b5919b4f Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Tue, 5 Feb 2019 09:42:16 -0500 Subject: [PATCH 21/34] Add der ca and update docs Add der ca and update docs --- README.md | 23 +++++++++++++++++++++-- certs/CA/cacert.crt | Bin 0 -> 1028 bytes 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 certs/CA/cacert.crt diff --git a/README.md b/README.md index 452893b..f8d51a3 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,8 @@ Bumper *can* be used with the official app, but with limitations. Your phone nee ![Example of emailing CA cert](docs/images/emailcert.png) - Import the cert as a CA, and trust it - - Instructions here are different for iPhone vs Android -##### Importing the CA Cert on iOS + - Instructions here are different for [iOS](#importing-the-ca-cert-on-ios) vs [Android](#importing-the-ca-cert-on-android) +#### Importing the CA Cert on iOS 1. Open the e-mail on your iOS device, and click the attached cert @@ -108,6 +108,25 @@ Bumper *can* be used with the official app, but with limitations. Your phone nee 8. Click continue when prompted 9. That's it, you can now [Use the app](#use-the-app) +#### Importing the CA Cert on Android + +1. Open the e-mail on your Android device + +**Quick Method** + +2. Click the cert, and if prompted provide a name +3. Under "Used for", select "VPN and apps" + +**Long Method** + +2. Save the attached cert file +3. Go to Settings > Lock screen and security > Other security settings +4. Under "Credential storage", click "Install from device storage" +5. Browse to the downloaded cert, select it, then click "Done" +6. Click the cert, and if prompted provide a name +7. Under "Used for", select "VPN and apps" + +Now, start [using the app](#use-the-app). ### Use the app diff --git a/certs/CA/cacert.crt b/certs/CA/cacert.crt new file mode 100644 index 0000000000000000000000000000000000000000..6d6a03e51e8dfd03c1acb11833e3afd527093b36 GIT binary patch literal 1028 zcmXqLVqq|7VtTQFnTe5!iIZXGUN^DQnR~Yw@Un4gwRyCC=VfGMWo0l3HRLwnWMd9x zVH0Kw4K@@u5Cm~Jgt?p(b28KNi}ErP4f%nZfP!4YY)+-Q1*t_a0d8~wW|)A1oH(zc zrGb%wp@FHXrLj?zIIoeVfgun>xU{miiBSpJgN&>U%uS5^z<^-nVrpV!WLTBAUUa54 zr_sXY-tWE(XB?d3&si8UlSx*ZxGD!=!Bn7#5_j%PlnaM`u!jzfRCyG48~ z?{{qtT+w{6_|&S8+qYOe{4mjUU;eR~H4oq4Jn-)7?;l_JmrvVVG4+}jORcq`(>CJ+ zW;$#ib8idI6tgQocI^J!sSZ0V7d1Kj+x@LOndjKFb$_joS1$P)@7LU}ZoE7HymMP< zaSo40VrN>><_WepLV~lb!pj(0Bi2;d@^S@j*}hNPZ&ucqAB*f2XKve4^84xmYUtuHB-v?PJd(C(GL6Lj8i_?u37bY zf!2%TOw5c7jEfsD88n_ZkOju6EFX&)i%6X1@e0FcgTSVn9kYwMi{87GR9`h{Yz4_H zv(y>XG+{$b>75BTG7sV>ffl Y+S3=l^{UJd;kamdOKRme>65{!02F6}e*gdg literal 0 HcmV?d00001 From da286b7fa975eabda12cb848b011a46b3a43fe1a Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Tue, 5 Feb 2019 09:50:36 -0500 Subject: [PATCH 22/34] Update doc for crt Update doc for crt --- README.md | 3 ++- docs/images/emailcert.png | Bin 37810 -> 35970 bytes docs/images/ios_email_cert.png | Bin 22078 -> 21614 bytes 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f8d51a3..bd439d9 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,8 @@ Bumper *can* be used with the official app, but with limitations. Your phone nee ### DNS - Configure your DNS server as described above in the [DNS](#dns) section. ### Import the Bumper CA Cert -- E-mail yourself the Bumper CA cert (located at `./certs/CA/cacert.pem`) +- E-mail yourself the Bumper CA cert (located at `./certs/CA/cacert.crt`) +**Note:** Make sure you select the `cacert.crt` file, which is a DER encoded version that will work on either Android or iOS. ![Example of emailing CA cert](docs/images/emailcert.png) diff --git a/docs/images/emailcert.png b/docs/images/emailcert.png index aebd1f30002a1d70a5dd41c0ef259bed7dc7830c..cac30a912a9ba6777693768bda8458447a7261c9 100644 GIT binary patch delta 32291 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 delta 34147 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 diff --git a/docs/images/ios_email_cert.png b/docs/images/ios_email_cert.png index 64fc93760c2c1d2b393f5c37ef643c0cc8683121..de413cf6bebba66fc3711863e33289d9572b62f5 100644 GIT binary patch delta 20431 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 From 8e1ba3a47510b9af40e6a6b53ca325bd8027df15 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Tue, 5 Feb 2019 10:36:37 -0500 Subject: [PATCH 23/34] Fix for Android app Fix for Android app Android and iOS are sending requests to usersapi differently, this handles both gracefully --- bumper/confserver.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/bumper/confserver.py b/bumper/confserver.py index 454e3be..8eb078d 100644 --- a/bumper/confserver.py +++ b/bumper/confserver.py @@ -195,22 +195,29 @@ class ConfServer(): async def handle_usersapi(self, request): body = {} - - json_body = json.loads(await request.text()) - todo = json_body['todo'] + postbody = {} + if request.content_type == "application/x-www-form-urlencoded": + postbody = await request.post() + + else: + postbody = json.loads(await request.text()) + + logging.debug(postbody) + + todo = postbody['todo'] if todo == 'FindBest': - service = json_body['service'] + service = postbody['service'] if service == 'EcoMsgNew': body = {"result":"ok","ip":socket.gethostbyname(socket.gethostname()),"port":5223} elif service == 'EcoUpdate': body = {"result":"ok","ip":"47.88.66.164","port":8005} elif todo == 'loginByItToken': body = { - "resource": json_body["resource"], + "resource": postbody["resource"], "result": "ok", "todo": "result", - "token": json_body["token"], #RandomChar(32) - "userId": json_body["userId"] #RandomChar(16) + "token": postbody["token"], #RandomChar(32) + "userId": postbody["userId"] #RandomChar(16) } elif todo == 'GetDeviceList': active_bots = self.bumper_bots.get() From eb4cb2b996bfcb3152826624f5fcb842a8f2eb9f Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Tue, 5 Feb 2019 22:08:51 -0500 Subject: [PATCH 24/34] Try except Try except --- bumper.py | 34 ++-- bumper/confserver.py | 425 ++++++++++++++++++++++++++----------------- bumper/mqttserver.py | 301 +++++++++++++++++------------- bumper/xmppserver.py | 9 +- 4 files changed, 456 insertions(+), 313 deletions(-) diff --git a/bumper.py b/bumper.py index 1a98928..8de2c7a 100644 --- a/bumper.py +++ b/bumper.py @@ -4,23 +4,18 @@ import logging import bumper import sys, socket import time +import platform args = sys.argv if len(args) > 0: if '--debug' in args: logging.basicConfig(level=logging.DEBUG, - format="[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s") + format="[%(asctime)s] :: %(levelname)s :: %(name)s :: %(module)s :: %(funcName)s :: %(lineno)d :: %(message)s") else: logging.basicConfig(level=logging.INFO, format="[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s") - -#conf_address = (socket.gethostbyname(socket.gethostname()), 443) -conf_address = ("0.0.0.0", 443) -#xmpp_address = (socket.gethostbyname(socket.gethostname()), 5223) -xmpp_address = ("0.0.0.0", 5223) -#mqtt_address = (socket.gethostbyname(socket.gethostname()), 8883) -mqtt_address = ("0.0.0.0", 8883) + #format="[%(asctime)s] :: %(levelname)s :: %(name)s :: %(module)s :: %(funcName)s :: %(lineno)d :: %(message)s") # A default bot could be set here to automatically add it as available # dbot = bumper.VacBotDevice("did", "class", "resource", "name","nick" ) @@ -29,13 +24,26 @@ mqtt_address = ("0.0.0.0", 8883) # bclienttemp.append(dbot.asdict()) # bclient.set(bclienttemp) -# start mqtt server (async) +if platform.system() == "Darwin": + listen_host = "0.0.0.0" +else: + listen_host = socket.gethostbyname(socket.gethostname()) + #listen_host = "localhost" #Try this if the above doesn't work + +conf_address_443 = (listen_host, 443) +conf_address_8007 = (listen_host, 8007) +xmpp_address = (listen_host, 5223) +mqtt_address = (listen_host, 8883) + +# start mqtt server on port 8883 (async) mqtt_server = bumper.MQTTServer(mqtt_address, run_async=True,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var) time.sleep(1.5) #Wait for broker startup -# start mqtt server (async) +# start mqtt_helperbot (async) mqtt_helperbot = bumper.MQTTHelperBot(mqtt_address, run_async=True,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var) -# start conf server (async) -conf_server = bumper.ConfServer(conf_address, usessl=True, run_async=True,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var, helperbot=mqtt_helperbot) -# start xmpp server (sync) +# start conf server on port 443 (async) - Used for most https calls +conf_server = bumper.ConfServer(conf_address_443, usessl=True, run_async=True,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var, helperbot=mqtt_helperbot) +# start conf server on port 8007 (async) - Used for a load balancer request +conf_server_2 = bumper.ConfServer(conf_address_8007, usessl=False, run_async=True,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var, helperbot=mqtt_helperbot) +# start xmpp server on port 5223 (sync) xmpp_server = bumper.XMPPServer(xmpp_address) diff --git a/bumper/confserver.py b/bumper/confserver.py index 8eb078d..96d3a9b 100644 --- a/bumper/confserver.py +++ b/bumper/confserver.py @@ -47,201 +47,288 @@ class ConfServer(): except: loop = asyncio.new_event_loop() - loop.run_until_complete(self.start_server()) - loop.run_forever() + try: + loop.run_until_complete(self.start_server()) + loop.run_forever() + except Exception as e: + logging.error('ConfServer: {}'.format(e)) async def start_server(self): - app = web.Application() - - app.add_routes([ - web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/login', self.handle_login), - # web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/checkLogin', self.handle_checkLogin), - web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/logout', self.handle_logout), - web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/getAuthCode', self.handle_getAuthCode), - web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/checkAgreement', self.handle_checkAgreement), - web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/common/checkVersion', self.handle_checkVersion), - web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/campaign/homePageAlert', self.handle_homePageAlert), - - web.post('/api/users/user.do', self.handle_usersapi), - web.post('/api/pim/product/getProductIotMap', self.handle_getProductIotMap), - web.post('/api/iot/devmanager.do', self.handle_devmanager_botcommand) - ]) - - - runner = web.AppRunner(app, access_log=None) #access_log=None so the output isn't nuts - await runner.setup() - - if self.usessl: - ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) - ssl_ctx.load_cert_chain(bumper.server_cert,bumper.server_key) - site = web.TCPSite(runner, host=self.address[0], port=self.address[1],ssl_context=ssl_ctx) + try: + app = web.Application() - else: - site = web.TCPSite(runner, host=self.address[0], port=self.address[1]) - - await site.start() - + app.add_routes([ + web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/login', self.handle_login), + # web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/checkLogin', self.handle_checkLogin), + web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/logout', self.handle_logout), + web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/getAuthCode', self.handle_getAuthCode), + web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/checkAgreement', self.handle_checkAgreement), + web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/common/checkVersion', self.handle_checkVersion), + web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/campaign/homePageAlert', self.handle_homePageAlert), + + web.post('/api/users/user.do', self.handle_usersapi), + web.get('/api/users/user.do', self.handle_usersapi), + web.post('/api/pim/product/getProductIotMap', self.handle_getProductIotMap), + web.post('/api/iot/devmanager.do', self.handle_devmanager_botcommand), + + web.post('/lookup.do', self.handle_lookup), + ]) + + + runner = web.AppRunner(app)#, access_log=None) #access_log=None so the output isn't nuts + await runner.setup() + + if self.usessl: + ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + ssl_ctx.load_cert_chain(bumper.server_cert,bumper.server_key) + site = web.TCPSite(runner, host=self.address[0], port=self.address[1],ssl_context=ssl_ctx) + + else: + site = web.TCPSite(runner, host=self.address[0], port=self.address[1]) + + await site.start() + + except PermissionError as e: + if "bind" in e.strerror: + logging.exception("Error binding confserver, exiting. Try using a different hostname or IP.\r\n {}".format(e)) + exit(1) + + except Exception as e: + logging.exception('ConfServer: {}'.format(e)) + exit(1) async def handle_login(self, request): - #Could implement basic auth if you wanted, or just accept anything - countrycode = request.match_info.get('country', "us") - body = { - "code": "0000", - "data": { - "accessToken": "tempaccesstoken", #Random chars 32 length - "country": countrycode, - "email": "null@null.com", - "uid": "fuid_{}".format(''.join(random.sample(string.ascii_letters,6))), #Date(14)_RandomChars(32) - "username": "fusername_{}".format(''.join(random.sample(string.ascii_letters,6))) #Random chars 8 - }, - "msg": "操作成功", - "time": bumper.get_milli_time(time.time()) - } - - return web.json_response(body) + try: + #Could implement basic auth if you wanted, or just accept anything + countrycode = request.match_info.get('country', "us") + body = { + "code": "0000", + "data": { + "accessToken": "tempaccesstoken", #Random chars 32 length + "country": countrycode, + "email": "null@null.com", + "uid": "fuid_{}".format(''.join(random.sample(string.ascii_letters,6))), #Date(14)_RandomChars(32) + "username": "fusername_{}".format(''.join(random.sample(string.ascii_letters,6))) #Random chars 8 + }, + "msg": "操作成功", + "time": bumper.get_milli_time(time.time()) + } + + return web.json_response(body) + + except Exception as e: + logging.error('ConfServer: {}'.format(e)) async def handle_checkLogin(self, request): - # The app seems to remember it's last uid and accessToken - # If these don't match, it fails - countrycode = request.match_info.get('country', "us") - body = { - "code": "0000", - "data": { - "accessToken": "tempaccesstoken", #Random chars 32 length - "country": countrycode, - "email": "null@null.com", - "uid": "fuid_{}".format(''.join(random.sample(string.ascii_letters,6))), #Date(14)_RandomChars(32) - "username": "fusername_{}".format(''.join(random.sample(string.ascii_letters,6))) #Random chars 8 - }, - "msg": "操作成功", - "time": bumper.get_milli_time(time.time()) - } - - return web.json_response(body) + try: + # The app seems to remember it's last uid and accessToken + # If these don't match, it fails + countrycode = request.match_info.get('country', "us") + body = { + "code": "0000", + "data": { + "accessToken": "tempaccesstoken", #Random chars 32 length + "country": countrycode, + "email": "null@null.com", + "uid": "fuid_{}".format(''.join(random.sample(string.ascii_letters,6))), #Date(14)_RandomChars(32) + "username": "fusername_{}".format(''.join(random.sample(string.ascii_letters,6))) #Random chars 8 + }, + "msg": "操作成功", + "time": bumper.get_milli_time(time.time()) + } + + return web.json_response(body) + + except Exception as e: + logging.error('ConfServer: {}'.format(e)) async def handle_logout(self, request): - body = {"code": "0000","data": None,"msg": "操作成功", "time": bumper.get_milli_time(time.time())} - #TODO - when logging out close out any other connections MQTT/XMPP - - return web.json_response(body) + try: + body = {"code": "0000","data": None,"msg": "操作成功", "time": bumper.get_milli_time(time.time())} + #TODO - when logging out close out any other connections MQTT/XMPP + + return web.json_response(body) + + except Exception as e: + logging.error('ConfServer: {}'.format(e)) async def handle_getAuthCode(self, request): - countrycode = request.match_info.get('country', "us") - body = { - "code": "0000", - "data": { - "authCode": "{}_tempauthcode".format(countrycode), #countrycode_randomchars(32) - "ecovacsUid": "fuid_{}".format(''.join(random.sample(string.ascii_letters,6))) #Date(14)_RandomChars(32) - }, - "msg": "操作成功", - "time": bumper.get_milli_time(time.time()) - } - - return web.json_response(body) + try: + countrycode = request.match_info.get('country', "us") + body = { + "code": "0000", + "data": { + "authCode": "{}_tempauthcode".format(countrycode), #countrycode_randomchars(32) + "ecovacsUid": "fuid_{}".format(''.join(random.sample(string.ascii_letters,6))) #Date(14)_RandomChars(32) + }, + "msg": "操作成功", + "time": bumper.get_milli_time(time.time()) + } + + return web.json_response(body) + + except Exception as e: + logging.error('ConfServer: {}'.format(e)) async def handle_checkVersion(self, request): - body = { - "code": "0000", - "data": { - "c": None, - "img": None, - "r": 0, - "t": None, - "u": None, - "ut": 0, - "v": None - }, - "msg": "操作成功", - "time": bumper.get_milli_time(time.time()) - } - - return web.json_response(body) + try: + body = { + "code": "0000", + "data": { + "c": None, + "img": None, + "r": 0, + "t": None, + "u": None, + "ut": 0, + "v": None + }, + "msg": "操作成功", + "time": bumper.get_milli_time(time.time()) + } + + return web.json_response(body) + + except Exception as e: + logging.error('ConfServer: {}'.format(e)) async def handle_checkAgreement(self, request): - body = { - "code": "0000", - "data": [], - "msg": "操作成功", - "time": bumper.get_milli_time(time.time()) - - } - - return web.json_response(body) + try: + body = { + "code": "0000", + "data": [], + "msg": "操作成功", + "time": bumper.get_milli_time(time.time()) + + } + + return web.json_response(body) + + except Exception as e: + logging.error('ConfServer: {}'.format(e)) async def handle_homePageAlert(self, request): - nextAlert = bumper.get_milli_time((datetime.now() + timedelta(hours=12)).timestamp()) - - body = { - "code": "0000", - "data": { - "clickSchemeUrl": None, - "clickWebUrl": None, - "hasCampaign": "N", - "imageUrl": None, - "nextAlertTime": nextAlert, - "serverTime": bumper.get_milli_time(time.time()) - }, - "msg": "操作成功", - "time": bumper.get_milli_time(time.time()) - } - - return web.json_response(body) + try: + nextAlert = bumper.get_milli_time((datetime.now() + timedelta(hours=12)).timestamp()) + + body = { + "code": "0000", + "data": { + "clickSchemeUrl": None, + "clickWebUrl": None, + "hasCampaign": "N", + "imageUrl": None, + "nextAlertTime": nextAlert, + "serverTime": bumper.get_milli_time(time.time()) + }, + "msg": "操作成功", + "time": bumper.get_milli_time(time.time()) + } + + return web.json_response(body) + + except Exception as e: + logging.error('ConfServer: {}'.format(e)) async def handle_getProductIotMap(self, request): - #json_body = json.loads(await request.text()) - body = {"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]} - return web.json_response(body) + try: + #json_body = json.loads(await request.text()) + body = {"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]} + return web.json_response(body) + + except Exception as e: + logging.error('ConfServer: {}'.format(e)) async def handle_usersapi(self, request): - body = {} - postbody = {} - if request.content_type == "application/x-www-form-urlencoded": - postbody = await request.post() - - else: - postbody = json.loads(await request.text()) + try: + + body = {} + postbody = {} + if request.content_type == "application/x-www-form-urlencoded": + postbody = await request.post() - logging.debug(postbody) - - todo = postbody['todo'] - if todo == 'FindBest': - service = postbody['service'] - if service == 'EcoMsgNew': - body = {"result":"ok","ip":socket.gethostbyname(socket.gethostname()),"port":5223} - elif service == 'EcoUpdate': - body = {"result":"ok","ip":"47.88.66.164","port":8005} - elif todo == 'loginByItToken': - body = { - "resource": postbody["resource"], - "result": "ok", - "todo": "result", - "token": postbody["token"], #RandomChar(32) - "userId": postbody["userId"] #RandomChar(16) - } - elif todo == 'GetDeviceList': - active_bots = self.bumper_bots.get() - body = { - "devices": active_bots, + else: + postbody = json.loads(await request.text()) + + logging.debug(postbody) + + todo = postbody['todo'] + if todo == 'FindBest': + service = postbody['service'] + if service == 'EcoMsgNew': + body = {"result":"ok","ip":socket.gethostbyname(socket.gethostname()),"port":5223} + elif service == 'EcoUpdate': + body = {"result":"ok","ip":"47.88.66.164","port":8005} + elif todo == 'loginByItToken': + body = { + "resource": postbody["resource"], "result": "ok", - "todo": "result" - } - - return web.json_response(body) + "todo": "result", + "token": postbody["token"], #RandomChar(32) + "userId": postbody["userId"] #RandomChar(16) + } + elif todo == 'GetDeviceList': + active_bots = self.bumper_bots.get() + body = { + "devices": active_bots, + "result": "ok", + "todo": "result" + } + + return web.json_response(body) + + except Exception as e: + logging.error('ConfServer: {}'.format(e)) + + async def handle_lookup(self, request): + try: + + body = {} + postbody = {} + if request.content_type == "application/x-www-form-urlencoded": + postbody = await request.post() + + else: + postbody = json.loads(await request.text()) + + logging.debug(postbody) + + todo = postbody['todo'] + if todo == 'FindBest': + service = postbody['service'] + if service == 'EcoMsgNew': + body = {"result":"ok","ip":socket.gethostbyname(socket.gethostname()),"port":5223} + elif service == 'EcoUpdate': + body = {"result":"ok","ip":"47.88.66.164","port":8005} + + return web.json_response(body) + + except Exception as e: + logging.error('ConfServer: {}'.format(e)) async def handle_devmanager_botcommand(self, request): - json_body = json.loads(await request.text()) - randomid = ''.join(random.sample(string.ascii_letters,6)) - retcmd = await self.helperbot.send_command(json_body, randomid) - body = retcmd + try: + json_body = json.loads(await request.text()) + logging.info("Device Request: {}".format(json_body)) + randomid = ''.join(random.sample(string.ascii_letters,6)) + retcmd = await self.helperbot.send_command(json_body, randomid) + body = retcmd - return web.json_response(body) + logging.info("Device Response: {}".format(body)) + return web.json_response(body) + + except Exception as e: + logging.error('ConfServer: {}'.format(e)) def disconnect(self): - logging.info('ConfServer: shutting down...') - if(self.run_async): - self.server.join() - else: - self.server.disconnect() - logging.info('ConfServer: bye') - \ No newline at end of file + try: + logging.info('ConfServer: shutting down...') + if(self.run_async): + self.server.join() + else: + self.server.disconnect() + logging.info('ConfServer: bye') + + except Exception as e: + logging.error('ConfServer: {}'.format(e)) \ No newline at end of file diff --git a/bumper/mqttserver.py b/bumper/mqttserver.py index 83ce588..9e3933d 100644 --- a/bumper/mqttserver.py +++ b/bumper/mqttserver.py @@ -36,17 +36,19 @@ class MQTTHelperBot(): else: self.run_helperbot() - except: - logging.exception("Exception") + except Exception as e: + logging.error('Helperbot: {}'.format(e)) pass def run_helperbot(self, loop): - - asyncio.set_event_loop(loop) - self.Client = MQTTClient(client_id=self.client_id, config={'check_hostname':False}) - loop.run_until_complete(self.start_helper_bot()) - loop.run_until_complete(self.get_msg()) - loop.run_forever() + try: + asyncio.set_event_loop(loop) + self.Client = MQTTClient(client_id=self.client_id, config={'check_hostname':False}) + loop.run_until_complete(self.start_helper_bot()) + loop.run_until_complete(self.get_msg()) + loop.run_forever() + except Exception as e: + logging.error('Helperbot: {}'.format(e)) async def start_helper_bot(self): @@ -56,8 +58,11 @@ class MQTTHelperBot(): ('iot/p2p/+/+/+/+/helper1/bumper/helper1/+/+/+',QOS_0), ('iot/p2p/+',QOS_0) ]) - except hbmqtt.client.ClientException as ce: - logging.exception("Client exception: %s" % ce) + + except Exception as e: + logging.error('Helperbot: {}'.format(e)) + #except hbmqtt.client.ClientException as ce: + # logging.exception("Client exception: %s" % ce) async def get_msg(self): @@ -78,48 +83,58 @@ class MQTTHelperBot(): cresp.append({"time": time.time() ,"topic": message.topic,"payload":str(message.data.decode("utf-8"))}) self.command_responses.set(cresp) logging.debug("MQTT Command Response List Count: %s" %len(cresp)) - except hbmqtt.client.ClientException as ce: - logging.error("Client exception: %s" % ce) + + except Exception as e: + logging.error('Helperbot: {}'.format(e)) + #except hbmqtt.client.ClientException as ce: + # logging.error("Client exception: %s" % ce) async def wait_for_resp(self, requestid): - t_end = (datetime.now() + timedelta(seconds=10)).timestamp() - while time.time() < t_end: - await asyncio.sleep(0.1) - responses = self.command_responses.get() - if len(responses) > 0: - for msg in responses: - topic = str(msg['topic']).split("/") - if (topic[6] == "helper1" and topic[10] == requestid): - logging.debug('VacBot MQTT Response: Topic: %s Payload: %s' % (msg['topic'], msg['payload'])) - if topic[11] == "j": - resppayload = json.loads(msg['payload']) - else: - resppayload = str(msg['payload']) - resp = { - "id": requestid, - "ret": "ok", - "resp": resppayload - } - cresp = self.command_responses.get() - cresp.remove(msg) - self.command_responses.set(cresp) - return resp + try: + t_end = (datetime.now() + timedelta(seconds=10)).timestamp() + while time.time() < t_end: + await asyncio.sleep(0.1) + responses = self.command_responses.get() + if len(responses) > 0: + for msg in responses: + topic = str(msg['topic']).split("/") + if (topic[6] == "helper1" and topic[10] == requestid): + logging.debug('VacBot MQTT Response: Topic: %s Payload: %s' % (msg['topic'], msg['payload'])) + if topic[11] == "j": + resppayload = json.loads(msg['payload']) + else: + resppayload = str(msg['payload']) + resp = { + "id": requestid, + "ret": "ok", + "resp": resppayload + } + cresp = self.command_responses.get() + cresp.remove(msg) + self.command_responses.set(cresp) + return resp - return { "id": requestid, "errno": "timeout", "ret": "fail" } + return { "id": requestid, "errno": "timeout", "ret": "fail" } + + except Exception as e: + logging.error('Helperbot: {}'.format(e)) async def send_command(self, cmdjson, requestid): - - ttopic = "iot/p2p/{}/helper1/bumper/helper1/{}/{}/{}/q/{}/{}".format(cmdjson["cmdName"], - cmdjson["toId"], cmdjson["toType"], cmdjson["toRes"], requestid, cmdjson["payloadType"]) - try: - await self.Client.publish(ttopic, str(cmdjson["payload"]).encode(),QOS_0) - except: - logging.exception("Exception at send_command") - - resp = await self.wait_for_resp(requestid) - - return resp + try: + ttopic = "iot/p2p/{}/helper1/bumper/helper1/{}/{}/{}/q/{}/{}".format(cmdjson["cmdName"], + cmdjson["toId"], cmdjson["toType"], cmdjson["toRes"], requestid, cmdjson["payloadType"]) + try: + await self.Client.publish(ttopic, str(cmdjson["payload"]).encode(),QOS_0) + except: + logging.exception("Exception at send_command") + + resp = await self.wait_for_resp(requestid) + + return resp + + except Exception as e: + logging.error('Helperbot: {}'.format(e)) class MQTTServer(): @@ -127,25 +142,38 @@ class MQTTServer(): bumper_clients = [] bumper_bots = [] - async def broker_coro(self): - broker = hbmqtt.broker.Broker(config=self.default_config) - await broker.start() + async def broker_coro(self): + try: + broker = hbmqtt.broker.Broker(config=self.default_config) + await broker.start() + + except PermissionError as e: + if "bind" in e.strerror: + logging.exception("Error binding mqttserver, exiting. Try using a different hostname or IP.\r\n {}".format(e)) + exit(1) - async def active_bot_listing(self): - while True: - await asyncio.sleep(5) - logging.debug('Connected bots: %s' % self.bumper_bots.get()) + except Exception as e: + logging.exception('MQTTServer: {}'.format(e)) + exit(1) + + async def active_bot_listing(self): + try: + while True: + await asyncio.sleep(5) + logging.debug('Connected bots: %s' % self.bumper_bots.get()) + + except Exception as e: + logging.error('MQTTServer: {}'.format(e)) def __init__(self, address, run_async=False, bumper_bots=contextvars.ContextVar, bumper_clients=contextvars.ContextVar): - - #The below adds a plugin to the hbmqtt.broker.plugins without having to futz with setup.py - distribution = pkg_resources.Distribution("hbmqtt.broker.plugins") - bumper_plugin = pkg_resources.EntryPoint.parse('bumper = bumper.mqttserver:BumperMQTTServer_Plugin', dist=distribution) - distribution._ep_map = {"hbmqtt.broker.plugins": {"bumper": bumper_plugin}} - pkg_resources.working_set.add(distribution) - self.bumper_bots = bumper_bots - self.bumper_clients = bumper_clients - try: + try: + #The below adds a plugin to the hbmqtt.broker.plugins without having to futz with setup.py + distribution = pkg_resources.Distribution("hbmqtt.broker.plugins") + bumper_plugin = pkg_resources.EntryPoint.parse('bumper = bumper.mqttserver:BumperMQTTServer_Plugin', dist=distribution) + distribution._ep_map = {"hbmqtt.broker.plugins": {"bumper": bumper_plugin}} + pkg_resources.working_set.add(distribution) + self.bumper_bots = bumper_bots + self.bumper_clients = bumper_clients # Initialize bot server self.default_config = { 'listeners': { @@ -185,17 +213,24 @@ class MQTTServer(): else: self.run_server() + + except Exception as e: + logging.error('MQTTServer: {}'.format(e)) - except: - logging.exception("Exception") - pass + #except: + # logging.exception("Exception") + # pass - def run_server(self, loop): - asyncio.set_event_loop(loop) - loop.run_until_complete(self.broker_coro()) - #loop.run_until_complete(self.active_bot_listing()) - loop.run_forever() + def run_server(self, loop): + try: + asyncio.set_event_loop(loop) + loop.run_until_complete(self.broker_coro()) + #loop.run_until_complete(self.active_bot_listing()) + loop.run_forever() + + except Exception as e: + logging.error('MQTTServer: {}'.format(e)) class BumperMQTTServer_Plugin: def __init__(self, context): @@ -204,73 +239,81 @@ class BumperMQTTServer_Plugin: self.clients = self.context.config['clients'] except KeyError: self.context.logger.warning("'clients' section not found in context configuration") - + except Exception as e: + logging.error('MQTTServer: {}'.format(e)) async def on_broker_client_connected(self, client_id): - - logging.debug('Bumper Connection: %s connected' % client_id) - connected_bots = self.clients['connected_bots'].get() - connected_clients = self.clients['connected_clients'].get() - didsplit = str(client_id).split("@") - #If this isn't a fake user (fuid) then add as a bot - if not (str(didsplit[0]).startswith("fuid") or str(didsplit[0]).startswith("helper")): - tmpbotdetail = str(didsplit[1]).split("/") - newbot = bumper.VacBotDevice() - newbot.did = didsplit[0] - newbot.vac_bot_device_class = tmpbotdetail[0] - newbot.resource = tmpbotdetail[1] - botactive = False - for bot in connected_bots: - if bot['did'] == newbot.did: - botactive = True - - if botactive == False: - connected_bots.append(newbot.asdict()) - logging.info("Adding bot to list: {}".format(newbot.asdict())) - - self.clients['connected_bots'].set(connected_bots) - else: - tmpuserdetail = str(didsplit[1]).split("/") - newuser = bumper.VacBotUser() - newuser.userid = didsplit[0] - newuser.realm = tmpuserdetail[0] - newuser.resource = tmpuserdetail[1] - - clientactive = False - for client in connected_clients: - if client['userid'] == newuser.userid: - clientactive = True - - if clientactive == False: - connected_clients.append(newuser.asdict()) - logging.info("Adding client to list: {}".format(newuser.asdict())) - - self.clients['connected_clients'].set(connected_clients) + try: + logging.debug('Bumper Connection: %s connected' % client_id) + connected_bots = self.clients['connected_bots'].get() + connected_clients = self.clients['connected_clients'].get() + didsplit = str(client_id).split("@") + #If this isn't a fake user (fuid) then add as a bot + if not (str(didsplit[0]).startswith("fuid") or str(didsplit[0]).startswith("helper")): + tmpbotdetail = str(didsplit[1]).split("/") + newbot = bumper.VacBotDevice() + newbot.did = didsplit[0] + newbot.vac_bot_device_class = tmpbotdetail[0] + newbot.resource = tmpbotdetail[1] + botactive = False + for bot in connected_bots: + if bot['did'] == newbot.did: + botactive = True + + if botactive == False: + connected_bots.append(newbot.asdict()) + logging.info("Adding bot to list: {}".format(newbot.asdict())) + + self.clients['connected_bots'].set(connected_bots) + else: + tmpuserdetail = str(didsplit[1]).split("/") + newuser = bumper.VacBotUser() + newuser.userid = didsplit[0] + newuser.realm = tmpuserdetail[0] + newuser.resource = tmpuserdetail[1] + + clientactive = False + for client in connected_clients: + if client['userid'] == newuser.userid: + clientactive = True + + if clientactive == False: + connected_clients.append(newuser.asdict()) + logging.info("Adding client to list: {}".format(newuser.asdict())) + + self.clients['connected_clients'].set(connected_clients) - logging.debug('Connected Bots: %s' %self.clients['connected_bots'].get()) - logging.debug('Connected Clients: %s' %self.clients['connected_clients'].get()) + logging.debug('Connected Bots: %s' %self.clients['connected_bots'].get()) + logging.debug('Connected Clients: %s' %self.clients['connected_clients'].get()) + + except Exception as e: + logging.error('MQTTServer: {}'.format(e)) async def on_broker_client_disconnected(self, client_id): - logging.debug('Bumper Connection: %s disconnected' % client_id) - connected_bots = self.clients['connected_bots'].get() - connected_clients = self.clients['connected_clients'].get() - didsplit = str(client_id).split("@") - #If the did is in the list, remove it - for bot in connected_bots: - if didsplit[0] == bot['did']: - logging.info("Removing bot from list: {}".format(bot['did'])) - connected_bots.remove(bot) - self.clients['connected_bots'].set(connected_bots) + try: + logging.debug('Bumper Connection: %s disconnected' % client_id) + connected_bots = self.clients['connected_bots'].get() + connected_clients = self.clients['connected_clients'].get() + didsplit = str(client_id).split("@") + #If the did is in the list, remove it + for bot in connected_bots: + if didsplit[0] == bot['did']: + logging.info("Removing bot from list: {}".format(bot['did'])) + connected_bots.remove(bot) + self.clients['connected_bots'].set(connected_bots) - logging.debug('Connected Bots: %s' %self.clients['connected_bots'].get()) + logging.debug('Connected Bots: %s' %self.clients['connected_bots'].get()) - for client in connected_clients: - if didsplit[0] == client['userid']: - logging.info("Removing client from list: {}".format(client['userid'])) - connected_clients.remove(client) - self.clients['connected_clients'].set(connected_clients) + for client in connected_clients: + if didsplit[0] == client['userid']: + logging.info("Removing client from list: {}".format(client['userid'])) + connected_clients.remove(client) + self.clients['connected_clients'].set(connected_clients) - logging.debug('Connected Clients: %s' %self.clients['connected_clients'].get()) \ No newline at end of file + logging.debug('Connected Clients: %s' %self.clients['connected_clients'].get()) + + except Exception as e: + logging.error('MQTTServer: {}'.format(e)) \ No newline at end of file diff --git a/bumper/xmppserver.py b/bumper/xmppserver.py index eb0db11..519f678 100644 --- a/bumper/xmppserver.py +++ b/bumper/xmppserver.py @@ -32,10 +32,15 @@ class XMPPServer(): client.start() self.clients.append(client) self.socket.close() + except PermissionError as e: + if "bind" in e.strerror: + logging.exception("Error binding xmppserver, exiting. Try using a different hostname or IP.\r\n {}".format(e)) + exit(1) except Exception as e: - logging.error('XMPPServer: {}'.format(e)) + logging.exception('XMPPServer: {}'.format(e)) + exit(1) except KeyboardInterrupt: - logging.debug('XMPPServer: Keyboard interrupt') + logging.exception('XMPPServer: Keyboard interrupt') finally: self.disconnect() logging.info('XMPPServer: bye') From 5ff859a5ce1fcf7f29a1cc55300b06d70b82e200 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Wed, 6 Feb 2019 00:38:51 -0500 Subject: [PATCH 25/34] Update README.md --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bd439d9..15c68b3 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ The easiest way is overriding the main domains used by EcoVacs using DNSMasq/PiH address=/ecouser.net/{bumper server ip} address=/ecovacs.com/{bumper server ip} + address=/ecovacs.net/{bumper server ip} If this isn't an option, you'll need to configure your router DNS to point a number of domains used by the app/robot to the Bumper server. **Note:** Depending on country, your phone may be using a different domain. Most of these domains contain country-specific placeholders. @@ -49,11 +50,11 @@ If this isn't an option, you'll need to configure your router DNS to point a num | Address | Description | |--|--| -| `lbo.ecovacs.net` | Load-balancer that is checked by the app/robot | +| `lb-{countrycode}.ecovacs.net` | Load-balancer that is checked by the app/robot | | `eco-{countrycode}-api.ecovacs.com` | Used for Login | | `portal-{countrycode}.ecouser.net` | Used for Login and Rest API | | `msg-{countrycode}.ecouser.net` | Used for XMPP | -| `mq-{countrycode}.ecovacs.com` | Used for MQTT | +| `mq-ww.ecouser.net` | Used for MQTT | ### Starting Bumper - Start Bumper with `pipenv run python bumper.py` From a1b1b1a23bc1df01ee86b374797c2baa0b3f2cd1 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Wed, 6 Feb 2019 10:42:50 -0500 Subject: [PATCH 26/34] Manageable logging Manageable logging --- bumper.py | 89 ++++++++++++++++++++++++++---------------- bumper/confserver.py | 75 +++++++++++++++++++++--------------- bumper/mqttserver.py | 92 ++++++++++++++++++++++---------------------- bumper/xmppserver.py | 74 ++++++++++++++++++----------------- 4 files changed, 184 insertions(+), 146 deletions(-) diff --git a/bumper.py b/bumper.py index 8de2c7a..ed7af8d 100644 --- a/bumper.py +++ b/bumper.py @@ -6,44 +6,65 @@ import sys, socket import time import platform +bumperlog = logging.getLogger("bumper") +def main(): + args = sys.argv + if len(args) > 0: + if '--debug' in args: + logging.basicConfig(level=logging.DEBUG, + format="[%(asctime)s] :: %(levelname)s :: %(name)s :: %(module)s :: %(funcName)s :: %(lineno)d :: %(message)s") + else: + logging.basicConfig(level=logging.INFO, + format="[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s") + #format="[%(asctime)s] :: %(levelname)s :: %(name)s :: %(module)s :: %(funcName)s :: %(lineno)d :: %(message)s") -args = sys.argv -if len(args) > 0: - if '--debug' in args: - logging.basicConfig(level=logging.DEBUG, - format="[%(asctime)s] :: %(levelname)s :: %(name)s :: %(module)s :: %(funcName)s :: %(lineno)d :: %(message)s") + # A default bot could be set here to automatically add it as available + # dbot = bumper.VacBotDevice("did", "class", "resource", "name","nick" ) + # bclient = bumper.bumper_bots_var + # bclienttemp = bclient.get() + # bclienttemp.append(dbot.asdict()) + # bclient.set(bclienttemp) + + if platform.system() == "Darwin": + listen_host = "0.0.0.0" else: - logging.basicConfig(level=logging.INFO, - format="[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s") - #format="[%(asctime)s] :: %(levelname)s :: %(name)s :: %(module)s :: %(funcName)s :: %(lineno)d :: %(message)s") + listen_host = socket.gethostbyname(socket.gethostname()) + #listen_host = "localhost" #Try this if the above doesn't work -# A default bot could be set here to automatically add it as available -# dbot = bumper.VacBotDevice("did", "class", "resource", "name","nick" ) -# bclient = bumper.bumper_bots_var -# bclienttemp = bclient.get() -# bclienttemp.append(dbot.asdict()) -# bclient.set(bclienttemp) + conf_address_443 = (listen_host, 443) + conf_address_8007 = (listen_host, 8007) + xmpp_address = (listen_host, 5223) + mqtt_address = (listen_host, 8883) -if platform.system() == "Darwin": - listen_host = "0.0.0.0" -else: - listen_host = socket.gethostbyname(socket.gethostname()) - #listen_host = "localhost" #Try this if the above doesn't work + # start mqtt server on port 8883 (async) + startmqttserver = "Starting MQTT Server at {}".format(mqtt_address) + bumperlog.info("{}".format(startmqttserver)) + print("{}".format(startmqttserver)) + mqtt_server = bumper.MQTTServer(mqtt_address, run_async=True,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var) + time.sleep(1.5) #Wait for broker startup -conf_address_443 = (listen_host, 443) -conf_address_8007 = (listen_host, 8007) -xmpp_address = (listen_host, 5223) -mqtt_address = (listen_host, 8883) + # start mqtt_helperbot (async) + bumperlog.info("Starting MQTT HelperBot") + print("Starting MQTT HelperBot") + mqtt_helperbot = bumper.MQTTHelperBot(mqtt_address, run_async=True,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var) -# start mqtt server on port 8883 (async) -mqtt_server = bumper.MQTTServer(mqtt_address, run_async=True,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var) -time.sleep(1.5) #Wait for broker startup -# start mqtt_helperbot (async) -mqtt_helperbot = bumper.MQTTHelperBot(mqtt_address, run_async=True,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var) -# start conf server on port 443 (async) - Used for most https calls -conf_server = bumper.ConfServer(conf_address_443, usessl=True, run_async=True,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var, helperbot=mqtt_helperbot) -# start conf server on port 8007 (async) - Used for a load balancer request -conf_server_2 = bumper.ConfServer(conf_address_8007, usessl=False, run_async=True,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var, helperbot=mqtt_helperbot) -# start xmpp server on port 5223 (sync) -xmpp_server = bumper.XMPPServer(xmpp_address) + # start conf server on port 443 (async) - Used for most https calls + startconf443 = "Starting Main ConfServer at {}".format(conf_address_443) + bumperlog.info("{}".format(startconf443)) + print("{}".format(startconf443)) + conf_server = bumper.ConfServer(conf_address_443, usessl=True, run_async=True,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var, helperbot=mqtt_helperbot) + # start conf server on port 8007 (async) - Used for a load balancer request + startconf8007 = "Starting LoadBalancer ConfServer at {}".format(conf_address_8007) + bumperlog.info("{}".format(startconf8007)) + print("{}".format(startconf8007)) + conf_server_2 = bumper.ConfServer(conf_address_8007, usessl=False, run_async=True,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var, helperbot=mqtt_helperbot) + + # start xmpp server on port 5223 (sync) + startxmpp = "Starting XMPP Server at {}".format(xmpp_address) + bumperlog.info("{}".format(startxmpp)) + print("{}".format(startxmpp)) + xmpp_server = bumper.XMPPServer(xmpp_address) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/bumper/confserver.py b/bumper/confserver.py index 96d3a9b..a00aad3 100644 --- a/bumper/confserver.py +++ b/bumper/confserver.py @@ -11,6 +11,23 @@ import asyncio import contextvars from aiohttp import web +class aiohttp_filter(logging.Filter): + + def filter(self, record): + if record.name == "aiohttp.access" and record.levelno == 20: #Filters aiohttp.access log to switch it from INFO to DEBUG + record.levelno = 10 + record.levelname = "DEBUG" + + if record.levelno == 10 and logging.getLogger("confserver").getEffectiveLevel() == 10: + return True + else: + return False + +confserverlog = logging.getLogger("confserver") + +logging.getLogger("asyncio").setLevel(logging.CRITICAL + 1) #Ignore this logger +logging.getLogger("aiohttp.access").addFilter(aiohttp_filter()) + class ConfServer(): bumper_clients = contextvars.ContextVar bumper_bots = contextvars.ContextVar @@ -26,7 +43,7 @@ class ConfServer(): try: if run_async: - logging.debug("Starting ConfServer Thread: 1") + confserverlog.debug("Starting ConfServer Thread: 1") confserver = Thread(name="ConfServer_Thread",target=self.run_server) self.server = confserver confserver.setDaemon(True) @@ -38,7 +55,7 @@ class ConfServer(): except KeyboardInterrupt: self.disconnect() except Exception as e: - logging.error('ConfServer: {}'.format(e)) + confserverlog.exception('{}'.format(e)) def run_server(self): @@ -51,7 +68,7 @@ class ConfServer(): loop.run_until_complete(self.start_server()) loop.run_forever() except Exception as e: - logging.error('ConfServer: {}'.format(e)) + confserverlog.exception('{}'.format(e)) async def start_server(self): @@ -76,7 +93,7 @@ class ConfServer(): ]) - runner = web.AppRunner(app)#, access_log=None) #access_log=None so the output isn't nuts + runner = web.AppRunner(app) await runner.setup() if self.usessl: @@ -91,11 +108,11 @@ class ConfServer(): except PermissionError as e: if "bind" in e.strerror: - logging.exception("Error binding confserver, exiting. Try using a different hostname or IP.\r\n {}".format(e)) + confserverlog.exception("Error binding confserver, exiting. Try using a different hostname or IP - {}".format(e)) exit(1) except Exception as e: - logging.exception('ConfServer: {}'.format(e)) + confserverlog.exception('{}'.format(e)) exit(1) async def handle_login(self, request): @@ -118,7 +135,7 @@ class ConfServer(): return web.json_response(body) except Exception as e: - logging.error('ConfServer: {}'.format(e)) + confserverlog.exception('{}'.format(e)) async def handle_checkLogin(self, request): try: @@ -141,7 +158,7 @@ class ConfServer(): return web.json_response(body) except Exception as e: - logging.error('ConfServer: {}'.format(e)) + confserverlog.exception('{}'.format(e)) async def handle_logout(self, request): try: @@ -151,7 +168,7 @@ class ConfServer(): return web.json_response(body) except Exception as e: - logging.error('ConfServer: {}'.format(e)) + confserverlog.exception('{}'.format(e)) async def handle_getAuthCode(self, request): try: @@ -169,7 +186,7 @@ class ConfServer(): return web.json_response(body) except Exception as e: - logging.error('ConfServer: {}'.format(e)) + confserverlog.exception('{}'.format(e)) async def handle_checkVersion(self, request): try: @@ -191,7 +208,7 @@ class ConfServer(): return web.json_response(body) except Exception as e: - logging.error('ConfServer: {}'.format(e)) + confserverlog.exception('{}'.format(e)) async def handle_checkAgreement(self, request): try: @@ -206,7 +223,7 @@ class ConfServer(): return web.json_response(body) except Exception as e: - logging.error('ConfServer: {}'.format(e)) + confserverlog.exception('{}'.format(e)) async def handle_homePageAlert(self, request): try: @@ -229,7 +246,7 @@ class ConfServer(): return web.json_response(body) except Exception as e: - logging.error('ConfServer: {}'.format(e)) + confserverlog.exception('{}'.format(e)) async def handle_getProductIotMap(self, request): try: @@ -238,7 +255,7 @@ class ConfServer(): return web.json_response(body) except Exception as e: - logging.error('ConfServer: {}'.format(e)) + confserverlog.exception('{}'.format(e)) async def handle_usersapi(self, request): try: @@ -249,10 +266,8 @@ class ConfServer(): postbody = await request.post() else: - postbody = json.loads(await request.text()) - - logging.debug(postbody) - + postbody = json.loads(await request.text()) + todo = postbody['todo'] if todo == 'FindBest': service = postbody['service'] @@ -275,11 +290,12 @@ class ConfServer(): "result": "ok", "todo": "result" } - + + confserverlog.debug("\r\n POST: {} \r\n Response: {}".format(postbody,body)) return web.json_response(body) except Exception as e: - logging.error('ConfServer: {}'.format(e)) + confserverlog.exception('{}'.format(e)) async def handle_lookup(self, request): try: @@ -292,7 +308,7 @@ class ConfServer(): else: postbody = json.loads(await request.text()) - logging.debug(postbody) + confserverlog.debug(postbody) todo = postbody['todo'] if todo == 'FindBest': @@ -302,33 +318,32 @@ class ConfServer(): elif service == 'EcoUpdate': body = {"result":"ok","ip":"47.88.66.164","port":8005} + confserverlog.debug("\r\n POST: {} \r\n Response: {}".format(postbody,body)) return web.json_response(body) except Exception as e: - logging.error('ConfServer: {}'.format(e)) + confserverlog.exception('{}'.format(e)) async def handle_devmanager_botcommand(self, request): try: - json_body = json.loads(await request.text()) - logging.info("Device Request: {}".format(json_body)) + json_body = json.loads(await request.text()) randomid = ''.join(random.sample(string.ascii_letters,6)) retcmd = await self.helperbot.send_command(json_body, randomid) body = retcmd - logging.info("Device Response: {}".format(body)) + confserverlog.debug("\r\n POST: {} \r\n Response: {}".format(json_body,body)) return web.json_response(body) except Exception as e: - logging.error('ConfServer: {}'.format(e)) + confserverlog.exception('{}'.format(e)) def disconnect(self): try: - logging.info('ConfServer: shutting down...') + confserverlog.info('shutting down') if(self.run_async): self.server.join() else: - self.server.disconnect() - logging.info('ConfServer: bye') + self.server.disconnect() except Exception as e: - logging.error('ConfServer: {}'.format(e)) \ No newline at end of file + confserverlog.exception('{}'.format(e)) \ No newline at end of file diff --git a/bumper/mqttserver.py b/bumper/mqttserver.py index 9e3933d..4d97fbc 100644 --- a/bumper/mqttserver.py +++ b/bumper/mqttserver.py @@ -16,8 +16,17 @@ import bumper import json from datetime import datetime, timedelta +helperbotlog = logging.getLogger("helperbot") +mqttserverlog = logging.getLogger("mqttserver") + +logging.getLogger("transitions").setLevel(logging.CRITICAL + 1) #Ignore this logger +logging.getLogger("passlib").setLevel(logging.CRITICAL + 1) #Ignore this logger +logging.getLogger("hbmqtt.broker").setLevel(logging.CRITICAL + 1) #Ignore this logger #There are some sublogs that could be set if needed (.plugins) +logging.getLogger("hbmqtt.mqtt.protocol").setLevel(logging.CRITICAL + 1) #Ignore this logger +logging.getLogger("hbmqtt.client").setLevel(logging.CRITICAL + 1) #Ignore this logger class MQTTHelperBot(): + Client = MQTTClient() def __init__(self, address, run_async=False, bumper_bots=contextvars.ContextVar, bumper_clients=contextvars.ContextVar): @@ -28,7 +37,7 @@ class MQTTHelperBot(): try: if run_async: hloop = asyncio.new_event_loop() - logging.debug("Starting MQTT HelperBot Thread: 1") + helperbotlog.debug("Starting MQTT HelperBot Thread: 1") helperbot = Thread(name="MQTTHelperBot_Thread",target=self.run_helperbot, args=(hloop,)) helperbot.setDaemon(True) helperbot.start() @@ -37,7 +46,7 @@ class MQTTHelperBot(): self.run_helperbot() except Exception as e: - logging.error('Helperbot: {}'.format(e)) + helperbotlog.exception('{}'.format(e)) pass def run_helperbot(self, loop): @@ -48,7 +57,7 @@ class MQTTHelperBot(): loop.run_until_complete(self.get_msg()) loop.run_forever() except Exception as e: - logging.error('Helperbot: {}'.format(e)) + helperbotlog.exception('{}'.format(e)) async def start_helper_bot(self): @@ -60,35 +69,29 @@ class MQTTHelperBot(): ]) except Exception as e: - logging.error('Helperbot: {}'.format(e)) - #except hbmqtt.client.ClientException as ce: - # logging.exception("Client exception: %s" % ce) - + helperbotlog.exception('{}'.format(e)) async def get_msg(self): try: while True: message = await self.Client.deliver_message() - #logging.debug("HelperBot MQTT Received Message on Topic: {} - Message: {}".format(message.topic, str(message.payload.decode("utf-8")))) + #helperbotlog.debug("HelperBot MQTT Received Message on Topic: {} - Message: {}".format(message.topic, str(message.payload.decode("utf-8")))) cresp = self.command_responses.get() #Cleanup "expired messages" > 60 seconds from time for msg in cresp: expire_time = (datetime.fromtimestamp(msg['time']) + timedelta(seconds=10)).timestamp() if time.time() > expire_time: - #logging.debug("Pruning Message Time: {}, MsgTime: {}, MsgTime+60: {}".format(time.time(), msg['time'], expire_time)) + #helperbotlog.debug("Pruning Message Time: {}, MsgTime: {}, MsgTime+60: {}".format(time.time(), msg['time'], expire_time)) cresp.remove(msg) cresp.append({"time": time.time() ,"topic": message.topic,"payload":str(message.data.decode("utf-8"))}) self.command_responses.set(cresp) - logging.debug("MQTT Command Response List Count: %s" %len(cresp)) + #helperbotlog.debug("MQTT Command Response List Count: %s" %len(cresp)) except Exception as e: - logging.error('Helperbot: {}'.format(e)) - #except hbmqtt.client.ClientException as ce: - # logging.error("Client exception: %s" % ce) - + helperbotlog.exception('{}'.format(e)) async def wait_for_resp(self, requestid): try: @@ -100,7 +103,7 @@ class MQTTHelperBot(): for msg in responses: topic = str(msg['topic']).split("/") if (topic[6] == "helper1" and topic[10] == requestid): - logging.debug('VacBot MQTT Response: Topic: %s Payload: %s' % (msg['topic'], msg['payload'])) + #helperbotlog.debug('VacBot MQTT Response: Topic: %s Payload: %s' % (msg['topic'], msg['payload'])) if topic[11] == "j": resppayload = json.loads(msg['payload']) else: @@ -116,9 +119,10 @@ class MQTTHelperBot(): return resp return { "id": requestid, "errno": "timeout", "ret": "fail" } - + except asyncio.CancelledError as e: + helperbotlog.debug('wait_for_resp cancelled by asyncio') except Exception as e: - logging.error('Helperbot: {}'.format(e)) + helperbotlog.exception('{}'.format(e)) async def send_command(self, cmdjson, requestid): try: @@ -126,15 +130,15 @@ class MQTTHelperBot(): cmdjson["toId"], cmdjson["toType"], cmdjson["toRes"], requestid, cmdjson["payloadType"]) try: await self.Client.publish(ttopic, str(cmdjson["payload"]).encode(),QOS_0) - except: - logging.exception("Exception at send_command") + except Exception as e: + helperbotlog.exception("{}".format(e)) resp = await self.wait_for_resp(requestid) return resp except Exception as e: - logging.error('Helperbot: {}'.format(e)) + helperbotlog.exception('{}'.format(e)) class MQTTServer(): @@ -149,21 +153,21 @@ class MQTTServer(): except PermissionError as e: if "bind" in e.strerror: - logging.exception("Error binding mqttserver, exiting. Try using a different hostname or IP.\r\n {}".format(e)) + mqttserverlog.exception("Error binding mqttserver, exiting. Try using a different hostname or IP - {}".format(e)) exit(1) except Exception as e: - logging.exception('MQTTServer: {}'.format(e)) + mqttserverlog.exception('{}'.format(e)) exit(1) async def active_bot_listing(self): try: while True: await asyncio.sleep(5) - logging.debug('Connected bots: %s' % self.bumper_bots.get()) + mqttserverlog.debug('connected bots - %s' % self.bumper_bots.get()) except Exception as e: - logging.error('MQTTServer: {}'.format(e)) + mqttserverlog.exception('{}'.format(e)) def __init__(self, address, run_async=False, bumper_bots=contextvars.ContextVar, bumper_clients=contextvars.ContextVar): try: @@ -206,7 +210,7 @@ class MQTTServer(): } if run_async: sloop = asyncio.new_event_loop() - logging.debug("Starting MQTTServer Thread: 1") + mqttserverlog.debug("Starting MQTTServer Thread: 1") mqttserver = Thread(name="MQTTServer_Thread",target=self.run_server, args=(sloop,)) mqttserver.setDaemon(True) mqttserver.start() @@ -215,11 +219,7 @@ class MQTTServer(): self.run_server() except Exception as e: - logging.error('MQTTServer: {}'.format(e)) - - #except: - # logging.exception("Exception") - # pass + mqttserverlog.exception('{}'.format(e)) def run_server(self, loop): @@ -230,7 +230,7 @@ class MQTTServer(): loop.run_forever() except Exception as e: - logging.error('MQTTServer: {}'.format(e)) + mqttserverlog.exception('{}'.format(e)) class BumperMQTTServer_Plugin: def __init__(self, context): @@ -240,11 +240,11 @@ class BumperMQTTServer_Plugin: except KeyError: self.context.logger.warning("'clients' section not found in context configuration") except Exception as e: - logging.error('MQTTServer: {}'.format(e)) + mqttserverlog.exception('{}'.format(e)) async def on_broker_client_connected(self, client_id): try: - logging.debug('Bumper Connection: %s connected' % client_id) + #mqttserverlog.debug('%s connected' % client_id) connected_bots = self.clients['connected_bots'].get() connected_clients = self.clients['connected_clients'].get() didsplit = str(client_id).split("@") @@ -262,7 +262,7 @@ class BumperMQTTServer_Plugin: if botactive == False: connected_bots.append(newbot.asdict()) - logging.info("Adding bot to list: {}".format(newbot.asdict())) + mqttserverlog.info("new bot {}".format(newbot.did)) self.clients['connected_bots'].set(connected_bots) else: @@ -279,41 +279,39 @@ class BumperMQTTServer_Plugin: if clientactive == False: connected_clients.append(newuser.asdict()) - logging.info("Adding client to list: {}".format(newuser.asdict())) + mqttserverlog.info("new client {}".format(newuser.userid)) self.clients['connected_clients'].set(connected_clients) - - logging.debug('Connected Bots: %s' %self.clients['connected_bots'].get()) - logging.debug('Connected Clients: %s' %self.clients['connected_clients'].get()) + #mqttserverlog.debug('Connected Bots: %s' %self.clients['connected_bots'].get()) + #mqttserverlog.debug('Connected Clients: %s' %self.clients['connected_clients'].get()) except Exception as e: - logging.error('MQTTServer: {}'.format(e)) + mqttserverlog.exception('{}'.format(e)) async def on_broker_client_disconnected(self, client_id): try: - logging.debug('Bumper Connection: %s disconnected' % client_id) + #mqttserverlog.debug('%s disconnected' % client_id) connected_bots = self.clients['connected_bots'].get() connected_clients = self.clients['connected_clients'].get() didsplit = str(client_id).split("@") #If the did is in the list, remove it for bot in connected_bots: if didsplit[0] == bot['did']: - logging.info("Removing bot from list: {}".format(bot['did'])) + mqttserverlog.info("bot disconnected {}".format(bot['did'])) connected_bots.remove(bot) - self.clients['connected_bots'].set(connected_bots) - - logging.debug('Connected Bots: %s' %self.clients['connected_bots'].get()) + self.clients['connected_bots'].set(connected_bots) for client in connected_clients: if didsplit[0] == client['userid']: - logging.info("Removing client from list: {}".format(client['userid'])) + mqttserverlog.info("client disconnected {}".format(client['userid'])) connected_clients.remove(client) self.clients['connected_clients'].set(connected_clients) - logging.debug('Connected Clients: %s' %self.clients['connected_clients'].get()) + #mqttserverlog.debug('Connected Bots: %s' %self.clients['connected_bots'].get()) + #mqttserverlog.debug('Connected Clients: %s' %self.clients['connected_clients'].get()) except Exception as e: - logging.error('MQTTServer: {}'.format(e)) \ No newline at end of file + mqttserverlog.exception('{}'.format(e)) \ No newline at end of file diff --git a/bumper/xmppserver.py b/bumper/xmppserver.py index 519f678..f149d66 100644 --- a/bumper/xmppserver.py +++ b/bumper/xmppserver.py @@ -2,6 +2,7 @@ import sys, socket, threading, re, time, logging, uuid, xml.etree.ElementTree as ET +xmppserverlog = logging.getLogger("xmppserver") class XMPPServer(): server_id = 'bumper' @@ -17,9 +18,8 @@ class XMPPServer(): self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.socket.bind(address) self.socket.listen(1) - logging.info('XMPPServer: listening on {}:{}'.format(address[0], address[1])) + xmppserverlog.debug('listening on {}:{}'.format(address[0], address[1])) while not self.exit_flag: - logging.info('XMPPServer: awaiting connection') connection, client_address = self.socket.accept() # disconnect any clients with this ip for client in self.clients: @@ -34,27 +34,27 @@ class XMPPServer(): self.socket.close() except PermissionError as e: if "bind" in e.strerror: - logging.exception("Error binding xmppserver, exiting. Try using a different hostname or IP.\r\n {}".format(e)) + xmppserverlog.exception("Error binding XMPPServer, exiting. Try using a different hostname or IP - {}".format(e)) exit(1) except Exception as e: - logging.exception('XMPPServer: {}'.format(e)) + xmppserverlog.exception('{}'.format(e)) exit(1) - except KeyboardInterrupt: - logging.exception('XMPPServer: Keyboard interrupt') + except KeyboardInterrupt as e: + xmppserverlog.exception('{}'.format(e)) finally: self.disconnect() - logging.info('XMPPServer: bye') + xmppserverlog.info('disconnecting') def disconnect(self): try: - logging.info('XMPPServer: waiting for all client threads to exit') + xmppserverlog.debug('waiting for all client threads to exit') for client in self.clients: client.disconnect() client.join() self.exit_flag = True - logging.info('XMPPServer: shutting down...') + xmppserverlog.debug('shutting down') except Exception as e: - logging.exception("XMPPServer Exception: {}".format(e)) + xmppserverlog.exception("{}".format(e)) class Client(threading.Thread): @@ -80,23 +80,23 @@ class Client(threading.Thread): def send(self, command): try: - logging.debug('XMPPServer to {}: {}'.format(self.address, command)) + xmppserverlog.debug('send {} - {}'.format(self.address, command)) self.connection.send(command.encode()) except OSError as e: - logging.error('XMPPServer: {}'.format(e)) + xmppserverlog.error('{}'.format(e)) except Exception as e: - logging.exception("XMPPServer Exception: {}".format(e)) + xmppserverlog.exception("{}".format(e)) def disconnect(self): try: - logging.info('XMPPServer: {} with resource {} disconnecting'.format(self.address, self.clientresource)) + xmppserverlog.debug('client {} with resource {} disconnecting'.format(self.address, self.clientresource)) self.connection.close() self._set_state('DISCONNECT') except Exception as e: - logging.exception("XMPPServer Exception: {}".format(e)) + xmppserverlog.exception("{}".format(e)) def _tag_strip_uri(self, tag): try: @@ -104,25 +104,25 @@ class Client(threading.Thread): uri, ignore, tag = tag[1:].partition('}') return tag except Exception as e: - logging.exception("XMPPServer Exception: {}".format(e)) + xmppserverlog.exception("{}".format(e)) def _set_state(self, state): try: new_state = getattr(Client, state) if self.state > new_state: - raise Exception('XMPPServer: {} illegal state change {}->{}'.format(self.address, self.state, new_state)) - logging.debug('XMPPServer: {} state: {}'.format(self.address, state)) + raise Exception('{} illegal state change {}->{}'.format(self.address, self.state, new_state)) + xmppserverlog.debug('{} state: {}'.format(self.address, state)) self.state = new_state if new_state == '5': self.join() except Exception as e: - logging.exception("XMPPServer: Exception: {}".format(e)) + xmppserverlog.exception("{}".format(e)) def _handle_ctl(self, xml, data): try: ctl = xml[0][0] if ctl.get('admin') and self.type == self.BOT: - logging.debug('XMPPServer: admin username received from bot: {}'.format(ctl.get('admin'))) + xmppserverlog.debug('admin username received from bot: {}'.format(ctl.get('admin'))) XMPPServer.client_id = ctl.get('admin') return # forward @@ -136,7 +136,7 @@ class Client(threading.Thread): data = data.encode() client.send(data.decode('utf-8')) except Exception as e: - logging.exception("XMPPServer Exception: {}".format(e)) + xmppserverlog.exception("{}".format(e)) def _handle_ping(self, xml, data): @@ -155,19 +155,24 @@ class Client(threading.Thread): if client.address != self.address and client.state == client.READY: client.send(data.decode('utf-8')) except Exception as e: - logging.exception("XMPPServer Exception: {}".format(e)) + xmppserverlog.exception("{}".format(e)) def run(self): try: - logging.info('XMPPServer: client connected: {}'.format(self.address)) + xmppserverlog.info('client connected - {}'.format(self.address)) self._set_state('CONNECT') data = "" while True: time.sleep(0.2) - if not self.connection._closed: - data = self.connection.recv(4096) + if not self.connection._closed: + try: + data = self.connection.recv(4096) + except ConnectionResetError as e: + xmppserverlog.error('{}'.format(e)) + except Exception as e: + xmppserverlog.exception('{}'.format(e)) if data: - logging.debug('XMPPServer: from {}: {}'.format(self.address, data.decode('utf-8'))) + xmppserverlog.debug('from {} - {}'.format(self.address, data.decode('utf-8'))) try: if self.state == self.CONNECT: if data.decode('utf-8').find('jabber:client') > -1: @@ -179,7 +184,6 @@ class Client(threading.Thread): self.send('') continue xml = ET.fromstring(data) - logging.debug("XMPPXML: {}".format(data)) if len(xml): child = self._tag_strip_uri(xml[0].tag) else: @@ -191,7 +195,7 @@ class Client(threading.Thread): clientresourcexml = clientbindxml[0].getchildren() self.clientresource = clientresourcexml[0].text self.name = "XMPP Thread {}".format(self.clientresource) - logging.info("XMPP Client {} using resource {}".format(self.address, self.clientresource)) + xmppserverlog.debug("new client {} using resource {}".format(self.address, self.clientresource)) res = '{}'.format(xml.get('id'), XMPPServer.bot_id) self._set_state('BIND') elif child == 'session': @@ -209,27 +213,27 @@ class Client(threading.Thread): if len(xml) and xml[0].tag == 'status': # bot announcing arrival self.type = self.BOT - logging.debug('XMPPServer: {} type set to BOT (based on presence tag)'.format(self.address)) + xmppserverlog.debug('{} type set to BOT (based on presence tag)'.format(self.address)) # send a command from an unknown user - the response will contain the correct admin username self.send(''.format(uuid.uuid4(), 'unknown@ecouser.net', XMPPServer.bot_id)) elif xml.get('type') == 'available': self.type = self.CONTROLLER - logging.debug('XMPPServer: {} type set to CONTROLLER (based on presence tag)'.format(self.address)) + xmppserverlog.debug('{} type set to CONTROLLER (based on presence tag)'.format(self.address)) except ET.ParseError as e: - logging.debug('error: {}'.format(e)) + xmppserverlog.debug('parse error - {}'.format(e)) except Exception as e: - logging.error('XMPPServer: {}'.format(e)) + xmppserverlog.exception('{}'.format(e)) self._set_state('DISCONNECT') except OSError as e: - logging.error('XMPPServer: {}'.format(e)) + xmppserverlog.error('{}'.format(e)) self._set_state('DISCONNECT') except ConnectionResetError as e: - logging.error('XMPPServer: {}'.format(e)) + xmppserverlog.error('{}'.format(e)) self._set_state('DISCONNECT') except Exception as e: - logging.error('XMPPServer: {}'.format(e)) + xmppserverlog.exception('{}'.format(e)) self._set_state('DISCONNECT') finally: From acbcb8707a4d79ae752ee96922b638ec2f8d9282 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Fri, 15 Feb 2019 23:12:33 -0500 Subject: [PATCH 27/34] Revamp xmpp and rearranging code Rework XMPP to support iq and sasl auth Organize and rework the run code Support for threading --- bumper.py | 70 +++--- bumper/__init__.py | 17 ++ bumper/confserver.py | 41 +++- bumper/mqttserver.py | 107 +++++---- bumper/xmppserver.py | 533 +++++++++++++++++++++++++++++++++---------- 5 files changed, 564 insertions(+), 204 deletions(-) diff --git a/bumper.py b/bumper.py index ed7af8d..a6ba2ac 100644 --- a/bumper.py +++ b/bumper.py @@ -6,9 +6,10 @@ import sys, socket import time import platform -bumperlog = logging.getLogger("bumper") + def main(): args = sys.argv + if len(args) > 0: if '--debug' in args: logging.basicConfig(level=logging.DEBUG, @@ -18,14 +19,7 @@ def main(): format="[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s") #format="[%(asctime)s] :: %(levelname)s :: %(name)s :: %(module)s :: %(funcName)s :: %(lineno)d :: %(message)s") - # A default bot could be set here to automatically add it as available - # dbot = bumper.VacBotDevice("did", "class", "resource", "name","nick" ) - # bclient = bumper.bumper_bots_var - # bclienttemp = bclient.get() - # bclienttemp.append(dbot.asdict()) - # bclient.set(bclienttemp) - - if platform.system() == "Darwin": + if platform.system() == "Darwin": #If a Mac, use 0.0.0.0 for listening listen_host = "0.0.0.0" else: listen_host = socket.gethostbyname(socket.gethostname()) @@ -35,36 +29,46 @@ def main(): conf_address_8007 = (listen_host, 8007) xmpp_address = (listen_host, 5223) mqtt_address = (listen_host, 8883) + + xmpp_server = bumper.XMPPServer(xmpp_address) + mqtt_server = bumper.MQTTServer(mqtt_address,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var,remove_clients=bumper.bumper_removeclients_var) + mqtt_helperbot = bumper.MQTTHelperBot(mqtt_address, bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var) + conf_server = bumper.ConfServer(conf_address_443, usessl=True, bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var, remove_clients=bumper.bumper_removeclients_var,helperbot=mqtt_helperbot) + conf_server_2 = bumper.ConfServer(conf_address_8007, usessl=False, bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var, helperbot=mqtt_helperbot,remove_clients=bumper.bumper_removeclients_var) + + # start xmpp server on port 5223 (sync) + xmpp_server.run(run_async=True) #Start in new thread - # start mqtt server on port 8883 (async) - startmqttserver = "Starting MQTT Server at {}".format(mqtt_address) - bumperlog.info("{}".format(startmqttserver)) - print("{}".format(startmqttserver)) - mqtt_server = bumper.MQTTServer(mqtt_address, run_async=True,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var) + # start mqtt server on port 8883 (async) + mqtt_server.run(run_async=True) #Start in new thread + time.sleep(1.5) #Wait for broker startup - # start mqtt_helperbot (async) - bumperlog.info("Starting MQTT HelperBot") - print("Starting MQTT HelperBot") - mqtt_helperbot = bumper.MQTTHelperBot(mqtt_address, run_async=True,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var) + # start mqtt_helperbot (async) + mqtt_helperbot.run(run_async=True) #Start in new thread - # start conf server on port 443 (async) - Used for most https calls - startconf443 = "Starting Main ConfServer at {}".format(conf_address_443) - bumperlog.info("{}".format(startconf443)) - print("{}".format(startconf443)) - conf_server = bumper.ConfServer(conf_address_443, usessl=True, run_async=True,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var, helperbot=mqtt_helperbot) + # start conf server on port 443 (async) - Used for most https calls + conf_server.run(run_async=True) #Start in new thread - # start conf server on port 8007 (async) - Used for a load balancer request - startconf8007 = "Starting LoadBalancer ConfServer at {}".format(conf_address_8007) - bumperlog.info("{}".format(startconf8007)) - print("{}".format(startconf8007)) - conf_server_2 = bumper.ConfServer(conf_address_8007, usessl=False, run_async=True,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var, helperbot=mqtt_helperbot) + # start conf server on port 8007 (async) - Used for a load balancer request + conf_server_2.run(run_async=True) #Start in new thread - # start xmpp server on port 5223 (sync) - startxmpp = "Starting XMPP Server at {}".format(xmpp_address) - bumperlog.info("{}".format(startxmpp)) - print("{}".format(startxmpp)) - xmpp_server = bumper.XMPPServer(xmpp_address) + while True: + try: + time.sleep(0.25) + # WIP: Remove clients that have disconnected + remove_clients = bumper.bumper_removeclients_var.get() + if len(remove_clients) > 0: + for uid in remove_clients: + if uid != "": + xmpp_server.remove_client_byuid(uid) #Remove clients from xmpp server + remove_clients.remove(uid) + + bumper.bumper_removeclients_var.set(remove_clients) + + except KeyboardInterrupt: + print("Bumper Exiting") + if __name__ == "__main__": main() \ No newline at end of file diff --git a/bumper/__init__.py b/bumper/__init__.py index bfcdb9f..e38cf5b 100644 --- a/bumper/__init__.py +++ b/bumper/__init__.py @@ -7,13 +7,30 @@ from .xmppserver import XMPPServer import asyncio import contextvars import time +import logging bumper_clients_var = contextvars.ContextVar('bumper_clients', default=[]) bumper_bots_var = contextvars.ContextVar('bumper_bots', default=[]) +bumper_removeclients_var = contextvars.ContextVar('bumper_removeclients', default=[]) ca_cert = './certs/CA/cacert.pem' server_cert = './certs/cert.pem' server_key = './certs/key.pem' +#Logs +bumperlog = logging.getLogger("bumper") +confserverlog = logging.getLogger("confserver") +#Override the logging level +#confserverlog.setLevel(logging.INFO) +mqttserverlog = logging.getLogger("mqttserver") +#Override the logging level +#mqttserverlog.setLevel(logging.INFO) +helperbotlog = logging.getLogger("helperbot") +#Override the logging level +#helperbotlog.setLevel(logging.INFO) +xmppserverlog = logging.getLogger("xmppserver") +#Override the logging level +#xmppserverlog.setLevel(logging.INFO) + def get_milli_time(timetoconvert): return int(round(timetoconvert * 1000)) diff --git a/bumper/confserver.py b/bumper/confserver.py index a00aad3..501ca1f 100644 --- a/bumper/confserver.py +++ b/bumper/confserver.py @@ -32,33 +32,37 @@ class ConfServer(): bumper_clients = contextvars.ContextVar bumper_bots = contextvars.ContextVar - def __init__(self, address, usessl=False, run_async=True, bumper_bots=contextvars.ContextVar, bumper_clients=contextvars.ContextVar, helperbot=None): + def __init__(self, address, usessl=False, bumper_bots=contextvars.ContextVar, bumper_clients=contextvars.ContextVar, remove_clients=contextvars.ContextVar,helperbot=None): self.bumper_bots = bumper_bots self.bumper_clients = bumper_clients + self.remove_clients = remove_clients self.helperbot = helperbot self.usessl = usessl - self.run_async = run_async self.address = address - + self.confthread = None + + def run(self, run_async=False): try: if run_async: confserverlog.debug("Starting ConfServer Thread: 1") - confserver = Thread(name="ConfServer_Thread",target=self.run_server) - self.server = confserver - confserver.setDaemon(True) - confserver.start() + self.confthread = Thread(name="ConfServer_{}_Thread".format(self.address[1]),target=self.run_server) + self.confthread.setDaemon(True) + self.confthread.start() else: try: self.run_server() except KeyboardInterrupt: self.disconnect() + except Exception as e: confserverlog.exception('{}'.format(e)) def run_server(self): + logging.info("Starting ConfServer at {}".format(self.address)) + print("Starting ConfServer at {}".format(self.address)) try: loop = asyncio.get_event_loop() except: @@ -76,6 +80,7 @@ class ConfServer(): app = web.Application() app.add_routes([ + web.get('', self.handle_base), web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/login', self.handle_login), # web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/checkLogin', self.handle_checkLogin), web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/logout', self.handle_logout), @@ -115,6 +120,16 @@ class ConfServer(): confserverlog.exception('{}'.format(e)) exit(1) + async def handle_base(self, request): + try: + + text = "Bumper!" + + return web.json_response(text) + + except Exception as e: + confserverlog.exception('{}'.format(e)) + async def handle_login(self, request): try: #Could implement basic auth if you wanted, or just accept anything @@ -162,8 +177,14 @@ class ConfServer(): async def handle_logout(self, request): try: + uid = request.query['uid'] body = {"code": "0000","data": None,"msg": "操作成功", "time": bumper.get_milli_time(time.time())} - #TODO - when logging out close out any other connections MQTT/XMPP + # QUERY String + # 'uid=fuid_CUOVIn&accessToken=tempaccesstoken&requestId=e584de79f9cca854df6fb3352c6893b6&authTimespan=1550168440575&authTimeZone=GMT-5&authAppkey=eJUWrzRv34qFSaYk&authSign=14e38f95c7316e111c5815cf15f0f972' + if not uid == "": + remove_clients = self.remove_clients.get() + remove_clients.append(request.query['uid']) + self.remove_clients.set(remove_clients) return web.json_response(body) @@ -341,9 +362,9 @@ class ConfServer(): try: confserverlog.info('shutting down') if(self.run_async): - self.server.join() + self.confthread.join() else: - self.server.disconnect() + self.confthread.disconnect() except Exception as e: confserverlog.exception('{}'.format(e)) \ No newline at end of file diff --git a/bumper/mqttserver.py b/bumper/mqttserver.py index 4d97fbc..39e610b 100644 --- a/bumper/mqttserver.py +++ b/bumper/mqttserver.py @@ -28,28 +28,28 @@ logging.getLogger("hbmqtt.client").setLevel(logging.CRITICAL + 1) #Ignore this l class MQTTHelperBot(): Client = MQTTClient() - def __init__(self, address, run_async=False, bumper_bots=contextvars.ContextVar, bumper_clients=contextvars.ContextVar): - + def __init__(self, address, bumper_bots=contextvars.ContextVar, bumper_clients=contextvars.ContextVar): self.address = address self.client_id = "helper1@bumper/helper1" self.command_responses = contextvars.ContextVar('command_responses', default=[]) - - try: - if run_async: + self.helperthread = None + + + def run(self, run_async=False): + if run_async: hloop = asyncio.new_event_loop() helperbotlog.debug("Starting MQTT HelperBot Thread: 1") - helperbot = Thread(name="MQTTHelperBot_Thread",target=self.run_helperbot, args=(hloop,)) - helperbot.setDaemon(True) - helperbot.start() + self.helperthread = Thread(name="MQTTHelperBot_Thread",target=self.run_helperbot, args=(hloop,)) + self.helperthread.setDaemon(True) + self.helperthread.start() - else: - self.run_helperbot() + else: + self.run_helperbot() - except Exception as e: - helperbotlog.exception('{}'.format(e)) - pass - def run_helperbot(self, loop): + def run_helperbot(self, loop): + logging.info("Starting MQTT HelperBot") + print("Starting MQTT HelperBot") try: asyncio.set_event_loop(loop) self.Client = MQTTClient(client_id=self.client_id, config={'check_hostname':False}) @@ -77,7 +77,10 @@ class MQTTHelperBot(): message = await self.Client.deliver_message() #helperbotlog.debug("HelperBot MQTT Received Message on Topic: {} - Message: {}".format(message.topic, str(message.payload.decode("utf-8")))) - cresp = self.command_responses.get() + cresp = self.command_responses.get() + + if (str(message.topic).split("/")[6] == "helper1"): + cresp.append({"time": time.time() ,"topic": message.topic,"payload":str(message.data.decode("utf-8"))}) #Cleanup "expired messages" > 60 seconds from time for msg in cresp: @@ -85,8 +88,7 @@ class MQTTHelperBot(): if time.time() > expire_time: #helperbotlog.debug("Pruning Message Time: {}, MsgTime: {}, MsgTime+60: {}".format(time.time(), msg['time'], expire_time)) cresp.remove(msg) - - cresp.append({"time": time.time() ,"topic": message.topic,"payload":str(message.data.decode("utf-8"))}) + self.command_responses.set(cresp) #helperbotlog.debug("MQTT Command Response List Count: %s" %len(cresp)) @@ -95,7 +97,9 @@ class MQTTHelperBot(): async def wait_for_resp(self, requestid): try: + t_end = (datetime.now() + timedelta(seconds=10)).timestamp() + while time.time() < t_end: await asyncio.sleep(0.1) responses = self.command_responses.get() @@ -118,6 +122,7 @@ class MQTTHelperBot(): self.command_responses.set(cresp) return resp + return { "id": requestid, "errno": "timeout", "ret": "fail" } except asyncio.CancelledError as e: helperbotlog.debug('wait_for_resp cancelled by asyncio') @@ -169,15 +174,21 @@ class MQTTServer(): except Exception as e: mqttserverlog.exception('{}'.format(e)) - def __init__(self, address, run_async=False, bumper_bots=contextvars.ContextVar, bumper_clients=contextvars.ContextVar): + def __init__(self, address, bumper_bots=contextvars.ContextVar, bumper_clients=contextvars.ContextVar,remove_clients=contextvars.ContextVar): try: + + self.bumper_bots = bumper_bots + self.bumper_clients = bumper_clients + self.remove_clients = remove_clients + self.mqttserverthread = None + self.address = address + #The below adds a plugin to the hbmqtt.broker.plugins without having to futz with setup.py distribution = pkg_resources.Distribution("hbmqtt.broker.plugins") - bumper_plugin = pkg_resources.EntryPoint.parse('bumper = bumper.mqttserver:BumperMQTTServer_Plugin', dist=distribution) + bumper_plugin = pkg_resources.EntryPoint.parse('bumper = bumper.mqttserver:BumperMQTTServer_Plugin', dist=distribution) distribution._ep_map = {"hbmqtt.broker.plugins": {"bumper": bumper_plugin}} pkg_resources.working_set.add(distribution) - self.bumper_bots = bumper_bots - self.bumper_clients = bumper_clients + # Initialize bot server self.default_config = { 'listeners': { @@ -203,26 +214,32 @@ class MQTTServer(): 'enabled': False }, 'clients':{ - 'connected_bots': bumper_bots, - 'connected_clients': bumper_clients - - } - } - if run_async: - sloop = asyncio.new_event_loop() - mqttserverlog.debug("Starting MQTTServer Thread: 1") - mqttserver = Thread(name="MQTTServer_Thread",target=self.run_server, args=(sloop,)) - mqttserver.setDaemon(True) - mqttserver.start() - - else: - self.run_server() + 'connected_bots': self.bumper_bots, + 'connected_clients': self.bumper_clients, + 'remove_clients': self.remove_clients + }, + } except Exception as e: mqttserverlog.exception('{}'.format(e)) - - def run_server(self, loop): + def run(self, run_async=False,): + + if run_async: + sloop = asyncio.new_event_loop() + mqttserverlog.debug("Starting MQTTServer Thread: 1") + self.mqttserverthread = Thread(name="MQTTServer_Thread",target=self.run_server, args=(sloop,)) + self.mqttserverthread.setDaemon(True) + self.mqttserverthread.start() + + else: + self.run_server() + + + def run_server(self, loop): + + logging.info("Starting MQTT Server at {}".format(self.address)) + print("Starting MQTT Server at {}".format(self.address)) try: asyncio.set_event_loop(loop) loop.run_until_complete(self.broker_coro()) @@ -237,6 +254,7 @@ class BumperMQTTServer_Plugin: self.context = context try: self.clients = self.context.config['clients'] + except KeyError: self.context.logger.warning("'clients' section not found in context configuration") except Exception as e: @@ -277,7 +295,7 @@ class BumperMQTTServer_Plugin: if client['userid'] == newuser.userid: clientactive = True - if clientactive == False: + if clientactive == False and newuser.userid != 'helper1': connected_clients.append(newuser.asdict()) mqttserverlog.info("new client {}".format(newuser.userid)) @@ -295,20 +313,25 @@ class BumperMQTTServer_Plugin: try: #mqttserverlog.debug('%s disconnected' % client_id) connected_bots = self.clients['connected_bots'].get() - connected_clients = self.clients['connected_clients'].get() + connected_clients = self.clients['connected_clients'].get() + remove_clients = self.clients['remove_clients'].get() didsplit = str(client_id).split("@") #If the did is in the list, remove it for bot in connected_bots: if didsplit[0] == bot['did']: mqttserverlog.info("bot disconnected {}".format(bot['did'])) connected_bots.remove(bot) - self.clients['connected_bots'].set(connected_bots) + #remove_clients.append(bot['did']) + self.clients['connected_bots'].set(connected_bots) for client in connected_clients: - if didsplit[0] == client['userid']: - mqttserverlog.info("client disconnected {}".format(client['userid'])) + if didsplit[0] == client['userid'] and client['userid'] != 'helper1': + mqttserverlog.info("client disconnected {}".format(client['userid'])) connected_clients.remove(client) + #remove_clients.append(client['userid']) self.clients['connected_clients'].set(connected_clients) + + #self.clients['remove_clients'].set(remove_clients) #mqttserverlog.debug('Connected Bots: %s' %self.clients['connected_bots'].get()) #mqttserverlog.debug('Connected Clients: %s' %self.clients['connected_clients'].get()) diff --git a/bumper/xmppserver.py b/bumper/xmppserver.py index f149d66..e0a3228 100644 --- a/bumper/xmppserver.py +++ b/bumper/xmppserver.py @@ -1,6 +1,10 @@ #!/usr/bin/env python3 +from threading import Thread import sys, socket, threading, re, time, logging, uuid, xml.etree.ElementTree as ET +import base64 +import ssl +import bumper xmppserverlog = logging.getLogger("xmppserver") @@ -12,51 +16,115 @@ class XMPPServer(): exit_flag = False def __init__(self, address): + # Initialize bot server + self.address = address + + + def run(self, run_async=False): + if run_async: + xmppserverlog.debug("Starting XMPPServer Thread: 1") + self.xmppthread = Thread(name="XMPPServer_Thread",target=self.run_server) + self.xmppthread.setDaemon(True) + self.xmppthread.start() + + else: + try: + self.run_server() + except KeyboardInterrupt: + self.disconnect() + + def run_server(self): + logging.info("Starting XMPP Server at {}".format(self.address)) + print("Starting XMPP Server at {}".format(self.address)) + + #xmppserverlog.setLevel(logging.DEBUG) + + #Set SSL Context + self.ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + self.ssl_ctx.load_cert_chain(certfile=bumper.server_cert,keyfile=bumper.server_key) + + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: - # Initialize bot server - self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self.socket.bind(address) - self.socket.listen(1) - xmppserverlog.debug('listening on {}:{}'.format(address[0], address[1])) - while not self.exit_flag: + self.socket.bind(self.address) + self.socket.listen(5) + + xmppserverlog.debug('listening on {}:{}'.format(self.address[0], self.address[1])) + while not self.exit_flag: connection, client_address = self.socket.accept() + # disconnect any clients with this ip for client in self.clients: if client.address == client_address[0]: - client.disconnect() - client.join() - thread_id = uuid.uuid4() - client = Client(thread_id, connection, client_address) + xmppserverlog.debug('disconnecting existing client {} with resource {}'.format(client.address, client.clientresource)) + client._disconnect() + self.remove_client_byip(client.address) + xmppserverlog.debug('starting new client with ip {}'.format(client_address[0])) + thread_id = uuid.uuid4() + client = Client(thread_id, connection, client_address) + client.setDaemon(True) client.start() - self.clients.append(client) - self.socket.close() + self.clients.append(client) + except PermissionError as e: if "bind" in e.strerror: xmppserverlog.exception("Error binding XMPPServer, exiting. Try using a different hostname or IP - {}".format(e)) - exit(1) + exit(1) + except Exception as e: xmppserverlog.exception('{}'.format(e)) exit(1) + except KeyboardInterrupt as e: xmppserverlog.exception('{}'.format(e)) + finally: + connection.shutdown(socket.SHUT_RDWR) + connection.close() self.disconnect() - xmppserverlog.info('disconnecting') + xmppserverlog.info('disconnecting') + + self.socket.close() + + def disconnect(self): try: xmppserverlog.debug('waiting for all client threads to exit') for client in self.clients: - client.disconnect() - client.join() + client._disconnect() + self.exit_flag = True xmppserverlog.debug('shutting down') + except Exception as e: xmppserverlog.exception("{}".format(e)) + def remove_client_byip(self, ip): + for client in self.clients: + if client.address == ip: + xmppserverlog.debug('removing client from client list with ip {} and resource {}'.format(client.address, client.clientresource)) + client._disconnect() + self.clients.remove(client) + + def remove_client_byresource(self, resource): + for client in self.clients: + if str(client.clientresource).lower() == str(resource).lower(): + xmppserverlog.debug('removing client from client list with ip {} and resource {}'.format(client.address, client.clientresource)) + client._disconnect() + self.clients.remove(client) + + def remove_client_byuid(self, uid): + for client in self.clients: + if str(client.uid).lower() == str(uid).lower(): + xmppserverlog.debug('removing client from client list with ip {} and resource {}'.format(client.address, client.clientresource)) + client._disconnect() + self.clients.remove(client) + + class Client(threading.Thread): IDLE = 0 CONNECT = 1 @@ -68,32 +136,47 @@ class Client(threading.Thread): BOT = 1 CONTROLLER = 2 - def __init__(self, thread_id, connection, client_address): + def __init__(self, thread_id, connection, client_address): threading.Thread.__init__(self) self.id = thread_id - self.name = "XMPP Thread {}".format(client_address[0]) + self.name = "XMPP_Client_{}".format(client_address[0]) self.type = self.UNKNOWN self.state = self.IDLE self.connection = connection self.address = client_address[0] self.clientresource = "" + self.uid = "" + self.log_sent_message = False #Set to true to log sends + self.log_incoming_data = True #Set to true to log sends + + xmppserverlog.debug('new client thread init for client with ip {}'.format(self.address)) def send(self, command): try: - xmppserverlog.debug('send {} - {}'.format(self.address, command)) - self.connection.send(command.encode()) + if not self.connection._closed: + if self.log_sent_message: + xmppserverlog.debug('send {} - {}'.format(self.address, command)) + self.connection.send(command.encode()) + except OSError as e: - xmppserverlog.error('{}'.format(e)) + xmppserverlog.error('{}'.format(e)) + + except BrokenPipeError as e: + xmppserverlog.error('{}'.format(e)) + #self._set_state('DISCONNECT') + + except ConnectionResetError as e: + xmppserverlog.error('{}'.format(e)) + #self._set_state('DISCONNECT') + except Exception as e: xmppserverlog.exception("{}".format(e)) + - - - def disconnect(self): + def _disconnect(self): try: xmppserverlog.debug('client {} with resource {} disconnecting'.format(self.address, self.clientresource)) self.connection.close() - self._set_state('DISCONNECT') except Exception as e: xmppserverlog.exception("{}".format(e)) @@ -103,6 +186,7 @@ class Client(threading.Thread): if tag[0] == '{': uri, ignore, tag = tag[1:].partition('}') return tag + except Exception as e: xmppserverlog.exception("{}".format(e)) @@ -111,21 +195,40 @@ class Client(threading.Thread): new_state = getattr(Client, state) if self.state > new_state: raise Exception('{} illegal state change {}->{}'.format(self.address, self.state, new_state)) + xmppserverlog.debug('{} state: {}'.format(self.address, state)) + self.state = new_state - if new_state == '5': - self.join() + + if new_state == 5: + self._disconnect() + except Exception as e: xmppserverlog.exception("{}".format(e)) - def _handle_ctl(self, xml, data): + def _handle_ctl(self, xml, data): try: - ctl = xml[0][0] - if ctl.get('admin') and self.type == self.BOT: - xmppserverlog.debug('admin username received from bot: {}'.format(ctl.get('admin'))) - XMPPServer.client_id = ctl.get('admin') + + if data.decode('utf-8').find('roster') > -1: + #Return not-implemented for roster + self.send(''.format(xml.get('id'))) return - # forward + + if xml.get('type') == 'set': + if data.decode('utf-8').find('com:sf') > -1 and xml.get('to') == 'rl.ecorobot.net': #Android bind? Not sure what this does yet. + self.send(''.format(xml.get('id'), self.uid, XMPPServer.bot_id, self.clientresource)) + + else: + xmppserverlog.debug('Unknown set type: {}'.format(data.decode('utf-8'))) + + if xml[0][0]: + ctl = xml[0][0] + if ctl.get('admin') and self.type == self.BOT: + xmppserverlog.debug('admin username received from bot: {}'.format(ctl.get('admin'))) + XMPPServer.client_id = ctl.get('admin') + return + + #forward for client in XMPPServer.clients: if client.address != self.address and client.state == client.READY: if client.type == self.BOT: @@ -135,106 +238,298 @@ class Client(threading.Thread): data = data[:id_index] + 'from="' + XMPPServer.client_id + '" ' + data[id_index:] data = data.encode() client.send(data.decode('utf-8')) + except Exception as e: xmppserverlog.exception("{}".format(e)) def _handle_ping(self, xml, data): - if(xml.get('to').find('@') == -1): - # Ping to server - respond - self.send(''.format(xml.get('id'), xml.get('to'))) - else: - for client in XMPPServer.clients: - if client.address != self.address and client.state == client.READY: - client.send(data.decode('utf-8')) + try: + if(xml.get('to').find('@') == -1): + # Ping to server - respond + self.send(''.format(xml.get('id'), xml.get('to'))) - def _handle_result(self, data): + else: + for client in XMPPServer.clients: + if client.address != self.address and client.state == client.READY: + client.send(data.decode('utf-8')) + + except Exception as e: + xmppserverlog.exception('{}'.format(e)) + + def _handle_result(self, data): # forward try: for client in XMPPServer.clients: if client.address != self.address and client.state == client.READY: client.send(data.decode('utf-8')) + except Exception as e: - xmppserverlog.exception("{}".format(e)) + xmppserverlog.exception("{}".format(e)) + - def run(self): + def _handle_connect(self, data): try: - xmppserverlog.info('client connected - {}'.format(self.address)) - self._set_state('CONNECT') - data = "" - while True: - time.sleep(0.2) - if not self.connection._closed: - try: - data = self.connection.recv(4096) - except ConnectionResetError as e: - xmppserverlog.error('{}'.format(e)) - except Exception as e: - xmppserverlog.exception('{}'.format(e)) - if data: - xmppserverlog.debug('from {} - {}'.format(self.address, data.decode('utf-8'))) - try: - if self.state == self.CONNECT: - if data.decode('utf-8').find('jabber:client') > -1: - self._set_state('INIT') - # ack jabbr:client - self.send(''.format(XMPPServer.server_id)) - time.sleep(0.5) - # session - self.send('') - continue - xml = ET.fromstring(data) - if len(xml): - child = self._tag_strip_uri(xml[0].tag) - else: - child = None - if xml.tag == 'iq': - res = None - if child == 'bind': - clientbindxml = xml.getchildren() - clientresourcexml = clientbindxml[0].getchildren() - self.clientresource = clientresourcexml[0].text - self.name = "XMPP Thread {}".format(self.clientresource) - xmppserverlog.debug("new client {} using resource {}".format(self.address, self.clientresource)) - res = '{}'.format(xml.get('id'), XMPPServer.bot_id) - self._set_state('BIND') - elif child == 'session': - res = ''.format(xml.get('id')) - self._set_state('READY') - elif child == 'query': - self._handle_ctl(xml, data) - elif child == 'ping': - self._handle_ping(xml, data) - elif xml.get('type') == 'result': - self._handle_result(data) - if res: - self.send(res) - elif xml.tag == 'presence': - if len(xml) and xml[0].tag == 'status': - # bot announcing arrival - self.type = self.BOT - xmppserverlog.debug('{} type set to BOT (based on presence tag)'.format(self.address)) - # send a command from an unknown user - the response will contain the correct admin username - self.send(''.format(uuid.uuid4(), 'unknown@ecouser.net', XMPPServer.bot_id)) - elif xml.get('type') == 'available': - self.type = self.CONTROLLER - xmppserverlog.debug('{} type set to CONTROLLER (based on presence tag)'.format(self.address)) - except ET.ParseError as e: - xmppserverlog.debug('parse error - {}'.format(e)) - except Exception as e: - xmppserverlog.exception('{}'.format(e)) - self._set_state('DISCONNECT') - except OSError as e: - xmppserverlog.error('{}'.format(e)) - self._set_state('DISCONNECT') + + if self.state == self.CONNECT: + #Client first connecting, send our features - except ConnectionResetError as e: - xmppserverlog.error('{}'.format(e)) - self._set_state('DISCONNECT') - + if data.decode('utf-8').find('jabber:client') > -1: + # ack jabbr:client + #no STARTTLS + self.send(''.format(XMPPServer.server_id)) + #with STARTTLS + #self.send(''.format(XMPPServer.server_id)) + time.sleep(0.25) + # send authentication support for iq-auth (fallback) and SASL + self.send('PLAIN') + #self.send('') + + elif data.decode('utf-8').find('jabber:iq:auth') > -1: #Handle iq-auth + self._handle_iq_auth(data) + + elif data.decode('utf-8').find('urn:ietf:params:xml:ns:xmpp-sasl') > -1: #Handle SASL auth + self._handle_sasl_auth(data) + + elif self.state == self.INIT: + #Client getting session after authentication + if data.decode('utf-8').find('jabber:client') > -1: + # ack jabbr:client + self.send(''.format(XMPPServer.server_id)) + time.sleep(0.25) + # session + self.send('') + + else: #Handle init bind + xml = ET.fromstring(data.decode('utf-8')) + if len(xml): + child = self._tag_strip_uri(xml[0].tag) + else: + child = None + + if xml.tag == 'iq': + if child == 'bind': + self._handle_bind(xml) + except Exception as e: xmppserverlog.exception('{}'.format(e)) - self._set_state('DISCONNECT') + + + def _handle_iq_auth(self, data): + try: + xml = ET.fromstring(data.decode('utf-8')) + ctl = xml[0][0] + xmppserverlog.info("IQ AUTH XML: {}".format(xml)) + #Received username and auth tag, send username/password requirement + if xml.get('type') == 'get' and "auth}username" in ctl.tag and self.type == self.UNKNOWN: + self.send(''.format(xml.get('id'))) + + #Received username, password, resource - Handle auth here and return pass or fail + if xml.get('type') == 'set' and "auth}username" in ctl.tag and self.type == self.UNKNOWN: + xmlauth = xml[0].getchildren() + uid = '' + password = '' + resource = '' + for aitem in xmlauth: + if 'username' in aitem.tag: + self.uid = aitem.text - finally: - self.disconnect() + elif 'password' in aitem.tag: + password = aitem.text.split("/")[2] + + elif 'resource' in aitem.tag: + self.clientresource = aitem.text + + if True: + #Client authenticated, move to next state + self._set_state('INIT') + + #Successful auth + self.send(''.format(xml.get('id'))) + + else: + #Failed auth + self.send(''.format(xml.get('id'))) + + + + except ET.ParseError as e: + if "no element found" in e.msg: + xmppserverlog.debug('xml parse error - {} - {} - this is common with ecovac protocol'.format(data.decode('utf-8'), e)) + elif "not well-formed (invalid token)" in e.msg: + xmppserverlog.debug('xml parse error - {} - {}'.format(data.decode('utf-8'), e)) + else: + xmppserverlog.debug('xml parse error - {} - {}'.format(data.decode('utf-8'), e)) + + except Exception as e: + xmppserverlog.exception('{}'.format(e)) + + + def _handle_sasl_auth(self, data): + try: + xml = ET.fromstring(data.decode('utf-8')) + saslauth = base64.b64decode(xml.text).decode('utf-8').split("/") + username = saslauth[0] + username = saslauth[0].split('\x00')[1] + self.uid = username + resource = saslauth[1] + self.clientresource = resource + authcode = saslauth[2] + + if authcode == "us_tempauthcode": #Handle auth + #Send response + self.send('') #Success + + #Client authenticated, move to next state + self._set_state('INIT') + + else: + #Failed to authenticate + self.send('') #Fail + + except ET.ParseError as e: + if "no element found" in e.msg: + xmppserverlog.debug('xml parse error - {} - {} - this is common with ecovac protocol'.format(data.decode('utf-8'), e)) + elif "not well-formed (invalid token)" in e.msg: + xmppserverlog.debug('xml parse error - {} - {}'.format(data.decode('utf-8'), e)) + else: + xmppserverlog.debug('xml parse error - {} - {}'.format(data.decode('utf-8'), e)) + + except Exception as e: + xmppserverlog.exception('{}'.format(e)) + + def _handle_bind(self, xml): + try: + clientbindxml = xml.getchildren() + clientresourcexml = clientbindxml[0].getchildren() + if len(clientresourcexml) > 0: + self.clientresource = clientresourcexml[0].text + self.name = "XMPP_Client_{}".format(self.clientresource) + xmppserverlog.debug("new client {} using resource {}".format(self.address, self.clientresource)) + res = '{}@{}/{}'.format(xml.get('id'), self.uid, XMPPServer.bot_id, self.clientresource) + else: + xmppserverlog.debug("new client {}".format(self.address)) + res = '{}'.format(xml.get('id'), XMPPServer.bot_id) + + self._set_state('BIND') + self.send(res) + + except Exception as e: + xmppserverlog.exception('{}'.format(e)) + + def _handle_session(self, xml): + try: + res = ''.format(xml.get('id')) + self._set_state('READY') + self.send(res) + + except Exception as e: + xmppserverlog.exception('{}'.format(e)) + + def _handle_presence(self, xml): + try: + if len(xml) and xml[0].tag == 'status': + # bot announcing arrival + self.type = self.BOT + xmppserverlog.debug('{} type set to BOT (based on presence tag)'.format(self.address)) + # send a command from an unknown user - the response will contain the correct admin username + + self.send(''.format(uuid.uuid4(), 'unknown@ecouser.net', XMPPServer.bot_id)) + + else: + self.type = self.CONTROLLER + xmppserverlog.debug('{} type set to CONTROLLER (based on presence tag)'.format(self.address)) + self.send(' dummy '.format(self.uid, XMPPServer.bot_id, self.clientresource)) + + except Exception as e: + xmppserverlog.exception('{}'.format(e)) + + def _parse_data(self, data): + if self.log_incoming_data: + xmppserverlog.debug('from {} - {}'.format(self.address, data.decode('utf-8'))) + + try: + xml = ET.fromstring(data.decode('utf-8')) + self._handle_xml(xml, data) + + except ET.ParseError as e: + if "no element found" in e.msg: #Element not closed or not all bytes received + #Happens wth connect stream often + if ' - client is signalling end of session/disconnect + if not '' in data.decode('utf-8'): + xmppserverlog.error('xml parse error - {} - {}'.format(data.decode('utf-8'), e)) + else: + self.send("") #Close stream + + elif "junk after document element" in e.msg: #More than one xml doc in data + #try to split it + data0 = data.decode('utf-8') + data1 = data0[e.position[1]:] + data0 = data0[:e.position[1]] + #xmppserverlog.debug('xml parse error - {} - {} - split0: {} - split1: {}'.format(data.decode('utf-8'), e, data0, data1)) + self._parse_data(data0.encode('utf-8')) + self._parse_data(data1.encode('utf-8')) + + else: + xmppserverlog.debug('xml parse error - {} - {}'.format(data.decode('utf-8'), e)) + + except Exception as e: + xmppserverlog.exception('{}'.format(e)) + + def _handle_xml(self, xml, data): + try: + if self.state == self.CONNECT or self.state == self.INIT: + self._handle_connect(data) + + if len(xml): + child = self._tag_strip_uri(xml[0].tag) + else: + child = None + + if xml.tag == 'iq': + if child == 'bind': + self._handle_bind(xml) + elif child == 'session': + self._handle_session(xml) + elif child == 'query': + self._handle_ctl(xml, data) + elif child == 'ping': + self._handle_ping(xml, data) + elif xml.get('type') == 'result': + self._handle_result(data) + elif xml.tag == 'presence': + self._handle_presence(xml) + + except Exception as e: + xmppserverlog.exception('{}'.format(e)) + + + def run(self): + xmppserverlog.info('client connected - {}'.format(self.address)) + self._set_state('CONNECT') + + while not self.state == self.DISCONNECT and not self.connection._closed: + data = b'' + + time.sleep(0.2) + if not self.connection._closed: + try: + data = self.connection.recv(4096) + + except ConnectionResetError as e: + xmppserverlog.error('{}'.format(e)) + except OSError as e: + xmppserverlog.error('{}'.format(e)) + except Exception as e: + xmppserverlog.exception('{}'.format(e)) + + if data != b'': + self._parse_data(data) + \ No newline at end of file From 54325fbe299db8103144a743438343fc2e4cd7b9 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Sat, 16 Feb 2019 23:37:54 -0500 Subject: [PATCH 28/34] add basic auth Add a very basic authentication and user system --- bumper.py | 35 +++++---- bumper/__init__.py | 48 ++++++++++++- bumper/confserver.py | 168 +++++++++++++++++++++++++------------------ bumper/mqttserver.py | 8 +-- bumper/xmppserver.py | 29 +++++--- 5 files changed, 189 insertions(+), 99 deletions(-) diff --git a/bumper.py b/bumper.py index a6ba2ac..11aba3b 100644 --- a/bumper.py +++ b/bumper.py @@ -30,12 +30,21 @@ def main(): xmpp_address = (listen_host, 5223) mqtt_address = (listen_host, 8883) - xmpp_server = bumper.XMPPServer(xmpp_address) - mqtt_server = bumper.MQTTServer(mqtt_address,bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var,remove_clients=bumper.bumper_removeclients_var) + xmpp_server = bumper.XMPPServer(xmpp_address, bumper_users=bumper.bumper_users_var, bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var) + mqtt_server = bumper.MQTTServer(mqtt_address, bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var) mqtt_helperbot = bumper.MQTTHelperBot(mqtt_address, bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var) - conf_server = bumper.ConfServer(conf_address_443, usessl=True, bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var, remove_clients=bumper.bumper_removeclients_var,helperbot=mqtt_helperbot) - conf_server_2 = bumper.ConfServer(conf_address_8007, usessl=False, bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var, helperbot=mqtt_helperbot,remove_clients=bumper.bumper_removeclients_var) + conf_server = bumper.ConfServer(conf_address_443, usessl=True,bumper_users=bumper.bumper_users_var, bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var,helperbot=mqtt_helperbot) + conf_server_2 = bumper.ConfServer(conf_address_8007, usessl=False,bumper_users=bumper.bumper_users_var, bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var, helperbot=mqtt_helperbot) + #add user + users = bumper.bumper_users_var.get() + user1 = bumper.BumperUser('user1') + user1.add_device('user_phone') + user1.add_bot('bot_id') + users.append(user1) + bumper.bumper_users_var.set(users) + + # start xmpp server on port 5223 (sync) xmpp_server.run(run_async=True) #Start in new thread @@ -58,17 +67,19 @@ def main(): time.sleep(0.25) # WIP: Remove clients that have disconnected - remove_clients = bumper.bumper_removeclients_var.get() - if len(remove_clients) > 0: - for uid in remove_clients: - if uid != "": - xmpp_server.remove_client_byuid(uid) #Remove clients from xmpp server - remove_clients.remove(uid) + # remove_clients = bumper.bumper_removeclients_var.get() + # if len(remove_clients) > 0: + # for uid in remove_clients: + # if uid != "": + # xmpp_server.remove_client_byuid(uid) #Remove clients from xmpp server + # remove_clients.remove(uid) - bumper.bumper_removeclients_var.set(remove_clients) + # bumper.bumper_removeclients_var.set(remove_clients) - except KeyboardInterrupt: + except KeyboardInterrupt: + bumper.bumperlog.info("Bumper Exiting - Keyboard Interrupt") print("Bumper Exiting") + exit(1) if __name__ == "__main__": main() \ No newline at end of file diff --git a/bumper/__init__.py b/bumper/__init__.py index e38cf5b..b39671c 100644 --- a/bumper/__init__.py +++ b/bumper/__init__.py @@ -8,10 +8,12 @@ import asyncio import contextvars import time import logging +from base64 import b64decode, b64encode +bumper_users_var = contextvars.ContextVar('bumper_users', default=[]) bumper_clients_var = contextvars.ContextVar('bumper_clients', default=[]) bumper_bots_var = contextvars.ContextVar('bumper_bots', default=[]) -bumper_removeclients_var = contextvars.ContextVar('bumper_removeclients', default=[]) + ca_cert = './certs/CA/cacert.pem' server_cert = './certs/cert.pem' server_key = './certs/key.pem' @@ -35,6 +37,47 @@ def get_milli_time(timetoconvert): return int(round(timetoconvert * 1000)) +class BumperUser(object): + def __init__(self,userid=""): + self.userid = userid + self.devices = [] + self.tokens = [] + self.authcodes = [] + self.bots = [] + + def add_device(self, devid): + if not devid in self.devices: + self.devices.append(devid) + + def remove_device(self, devid): + if devid in self.devices: + self.devices.remove(devid) + + + def add_token(self, token): + if not token in self.tokens: + self.tokens.append(token) + + def revoke_token(self, token): + if token in self.tokens: + self.tokens.remove(token) + + def add_authcode(self, authcode): + if not authcode in self.authcodes: + self.authcodes.append(authcode) + + def revoke_authcode(self, authcode): + if authcode in self.authcodes: + self.authcodes.remove(authcode) + + def add_bot(self, botdid): + if not botdid in self.bots: + self.bots.append(botdid) + + def remove_bot(self, botdid): + if botdid in self.bots: + self.bots.remove(botdid) + class VacBotDevice(object): def __init__(self,did="", vac_bot_device_class="",resource="" , name="", nick="", company="eco-ng"): self.vac_bot_device_class = vac_bot_device_class @@ -48,12 +91,11 @@ class VacBotDevice(object): return {"class": self.vac_bot_device_class, "company": self.company, "did": self.did, "name": self.name, "nick": self.nick, "resource": self.resource} -class VacBotUser(object): +class VacBotClient(object): def __init__(self,userid="",realm="",token=""): self.userid = userid self.realm = realm self.resource = token - def asdict(self): return {"userid": self.userid,"realm": self.realm,"resource": self.resource} diff --git a/bumper/confserver.py b/bumper/confserver.py index 501ca1f..ebebe5c 100644 --- a/bumper/confserver.py +++ b/bumper/confserver.py @@ -10,6 +10,7 @@ from datetime import datetime, timedelta import asyncio import contextvars from aiohttp import web +import uuid class aiohttp_filter(logging.Filter): @@ -32,10 +33,10 @@ class ConfServer(): bumper_clients = contextvars.ContextVar bumper_bots = contextvars.ContextVar - def __init__(self, address, usessl=False, bumper_bots=contextvars.ContextVar, bumper_clients=contextvars.ContextVar, remove_clients=contextvars.ContextVar,helperbot=None): + def __init__(self, address, usessl=False, bumper_users=contextvars.ContextVar, bumper_bots=contextvars.ContextVar, bumper_clients=contextvars.ContextVar, helperbot=None): + self.bumper_users = bumper_users self.bumper_bots = bumper_bots - self.bumper_clients = bumper_clients - self.remove_clients = remove_clients + self.bumper_clients = bumper_clients self.helperbot = helperbot self.usessl = usessl self.address = address @@ -82,7 +83,7 @@ class ConfServer(): app.add_routes([ web.get('', self.handle_base), web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/login', self.handle_login), - # web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/checkLogin', self.handle_checkLogin), + web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/checkLogin', self.handle_login), web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/logout', self.handle_logout), web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/getAuthCode', self.handle_getAuthCode), web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/checkAgreement', self.handle_checkAgreement), @@ -96,6 +97,9 @@ class ConfServer(): web.post('/lookup.do', self.handle_lookup), ]) + #Direct register from app: + #/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/directRegister + runner = web.AppRunner(app) @@ -130,61 +134,65 @@ class ConfServer(): except Exception as e: confserverlog.exception('{}'.format(e)) - async def handle_login(self, request): + async def handle_login(self, request): + try: - #Could implement basic auth if you wanted, or just accept anything - countrycode = request.match_info.get('country', "us") + user_devid = request.match_info.get('devid', "") + countrycode = request.match_info.get('country', "us") + if not user_devid == "": #Performing basic "auth" using devid, super insecure + users = bumper.bumper_users_var.get() + for user in users: + if user_devid in user.devices: + tmpaccesstoken = '' + if 'checkLogin' in request.path: + if request.query['accessToken'] in user.tokens and request.query['uid'] == "fuid_{}".format(user.userid): + tmpaccesstoken = request.query['accessToken'] + else: + if tmpaccesstoken == '': + tmpaccesstoken = uuid.uuid4().hex + user.add_token(tmpaccesstoken) + + body = { + "code": "0000", + "data": { + "accessToken": tmpaccesstoken, #Random chars 32 length + "country": countrycode, + "email": "null@null.com", + "uid": "fuid_{}".format(user.userid), + "username": "fusername_{}".format(user.userid), + }, + "msg": "操作成功", + "time": bumper.get_milli_time(time.time()) + } + bumper.bumper_users_var.set(users) + return web.json_response(body) + body = { - "code": "0000", - "data": { - "accessToken": "tempaccesstoken", #Random chars 32 length - "country": countrycode, - "email": "null@null.com", - "uid": "fuid_{}".format(''.join(random.sample(string.ascii_letters,6))), #Date(14)_RandomChars(32) - "username": "fusername_{}".format(''.join(random.sample(string.ascii_letters,6))) #Random chars 8 - }, - "msg": "操作成功", + "code": "1005", + "data": None, + "msg": "当前密码错误", "time": bumper.get_milli_time(time.time()) - } - + } + return web.json_response(body) + except Exception as e: confserverlog.exception('{}'.format(e)) - async def handle_checkLogin(self, request): - try: - # The app seems to remember it's last uid and accessToken - # If these don't match, it fails - countrycode = request.match_info.get('country', "us") - body = { - "code": "0000", - "data": { - "accessToken": "tempaccesstoken", #Random chars 32 length - "country": countrycode, - "email": "null@null.com", - "uid": "fuid_{}".format(''.join(random.sample(string.ascii_letters,6))), #Date(14)_RandomChars(32) - "username": "fusername_{}".format(''.join(random.sample(string.ascii_letters,6))) #Random chars 8 - }, - "msg": "操作成功", - "time": bumper.get_milli_time(time.time()) - } - - return web.json_response(body) - - except Exception as e: - confserverlog.exception('{}'.format(e)) async def handle_logout(self, request): - try: - uid = request.query['uid'] - body = {"code": "0000","data": None,"msg": "操作成功", "time": bumper.get_milli_time(time.time())} - # QUERY String - # 'uid=fuid_CUOVIn&accessToken=tempaccesstoken&requestId=e584de79f9cca854df6fb3352c6893b6&authTimespan=1550168440575&authTimeZone=GMT-5&authAppkey=eJUWrzRv34qFSaYk&authSign=14e38f95c7316e111c5815cf15f0f972' - if not uid == "": - remove_clients = self.remove_clients.get() - remove_clients.append(request.query['uid']) - self.remove_clients.set(remove_clients) + try: + user_devid = request.match_info.get('devid', "") + if not user_devid == "": + users = bumper.bumper_users_var.get() + for user in users: + if user_devid in user.devices: + if request.query['uid'] == "fuid_{}".format(user.userid) and request.query['accessToken'] in user.tokens: + user.revoke_token(request.query['accessToken']) + bumper.bumper_users_var.set(users) + + body = {"code": "0000","data": None,"msg": "操作成功", "time": bumper.get_milli_time(time.time())} return web.json_response(body) @@ -193,19 +201,37 @@ class ConfServer(): async def handle_getAuthCode(self, request): try: - countrycode = request.match_info.get('country', "us") - body = { - "code": "0000", - "data": { - "authCode": "{}_tempauthcode".format(countrycode), #countrycode_randomchars(32) - "ecovacsUid": "fuid_{}".format(''.join(random.sample(string.ascii_letters,6))) #Date(14)_RandomChars(32) - }, - "msg": "操作成功", - "time": bumper.get_milli_time(time.time()) - } - - return web.json_response(body) + + user_devid = request.match_info.get('devid', "") + if not user_devid == "": + users = bumper.bumper_users_var.get() + for user in users: + if user_devid in user.devices and request.query['accessToken'] in user.tokens: + countrycode = request.match_info.get('country', "us") + tmpauthcode = "{}_{}".format(countrycode,uuid.uuid4().hex) + user.add_authcode(tmpauthcode) + + body = { + "code": "0000", + "data": { + "authCode": tmpauthcode, + "ecovacsUid": request.query['uid'] + }, + "msg": "操作成功", + "time": bumper.get_milli_time(time.time()) + } + bumper.bumper_users_var.set(users) + return web.json_response(body) + body = { + "code": "1005", + "data": None, + "msg": "当前密码错误", + "time": bumper.get_milli_time(time.time()) + } + + return web.json_response(body) + except Exception as e: confserverlog.exception('{}'.format(e)) @@ -271,7 +297,6 @@ class ConfServer(): async def handle_getProductIotMap(self, request): try: - #json_body = json.loads(await request.text()) body = {"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]} return web.json_response(body) @@ -297,13 +322,18 @@ class ConfServer(): elif service == 'EcoUpdate': body = {"result":"ok","ip":"47.88.66.164","port":8005} elif todo == 'loginByItToken': - body = { - "resource": postbody["resource"], - "result": "ok", - "todo": "result", - "token": postbody["token"], #RandomChar(32) - "userId": postbody["userId"] #RandomChar(16) - } + + users = bumper.bumper_users_var.get() + for user in users: + if postbody['userId'] == "fuid_{}".format(user.userid) and postbody['token'] in user.authcodes: + body = { + "resource": postbody["resource"], + "result": "ok", + "todo": "result", + "token": postbody["token"], + "userId": postbody["userId"] + } + elif todo == 'GetDeviceList': active_bots = self.bumper_bots.get() body = { diff --git a/bumper/mqttserver.py b/bumper/mqttserver.py index 39e610b..e7ee992 100644 --- a/bumper/mqttserver.py +++ b/bumper/mqttserver.py @@ -174,12 +174,11 @@ class MQTTServer(): except Exception as e: mqttserverlog.exception('{}'.format(e)) - def __init__(self, address, bumper_bots=contextvars.ContextVar, bumper_clients=contextvars.ContextVar,remove_clients=contextvars.ContextVar): + def __init__(self, address, bumper_bots=contextvars.ContextVar, bumper_clients=contextvars.ContextVar): try: self.bumper_bots = bumper_bots self.bumper_clients = bumper_clients - self.remove_clients = remove_clients self.mqttserverthread = None self.address = address @@ -216,7 +215,6 @@ class MQTTServer(): 'clients':{ 'connected_bots': self.bumper_bots, 'connected_clients': self.bumper_clients, - 'remove_clients': self.remove_clients }, } @@ -285,7 +283,7 @@ class BumperMQTTServer_Plugin: self.clients['connected_bots'].set(connected_bots) else: tmpuserdetail = str(didsplit[1]).split("/") - newuser = bumper.VacBotUser() + newuser = bumper.VacBotClient() newuser.userid = didsplit[0] newuser.realm = tmpuserdetail[0] newuser.resource = tmpuserdetail[1] @@ -314,7 +312,7 @@ class BumperMQTTServer_Plugin: #mqttserverlog.debug('%s disconnected' % client_id) connected_bots = self.clients['connected_bots'].get() connected_clients = self.clients['connected_clients'].get() - remove_clients = self.clients['remove_clients'].get() + #remove_clients = self.clients['remove_clients'].get() didsplit = str(client_id).split("@") #If the did is in the list, remove it for bot in connected_bots: diff --git a/bumper/xmppserver.py b/bumper/xmppserver.py index e0a3228..4c1f800 100644 --- a/bumper/xmppserver.py +++ b/bumper/xmppserver.py @@ -4,6 +4,7 @@ from threading import Thread import sys, socket, threading, re, time, logging, uuid, xml.etree.ElementTree as ET import base64 import ssl +import contextvars import bumper xmppserverlog = logging.getLogger("xmppserver") @@ -15,9 +16,12 @@ class XMPPServer(): clients = [] exit_flag = False - def __init__(self, address): + def __init__(self, address, bumper_users=contextvars.ContextVar, bumper_bots=contextvars.ContextVar, bumper_clients=contextvars.ContextVar): # Initialize bot server self.address = address + self.bumper_users = bumper_users + self.bumper_bots = bumper_bots + self.bumper_clients = bumper_clients def run(self, run_async=False): @@ -86,9 +90,7 @@ class XMPPServer(): self.disconnect() xmppserverlog.info('disconnecting') - self.socket.close() - - + self.socket.close() def disconnect(self): try: @@ -339,8 +341,8 @@ class Client(threading.Thread): elif 'resource' in aitem.tag: self.clientresource = aitem.text - - if True: + + if self.check_authcode(self.uid, password): #Client authenticated, move to next state self._set_state('INIT') @@ -364,6 +366,13 @@ class Client(threading.Thread): except Exception as e: xmppserverlog.exception('{}'.format(e)) + def check_authcode(self, uid, authcode): + users = bumper.bumper_users_var.get() + for user in users: + if uid == "fuid_{}".format(user.userid) and authcode in user.authcodes: + return True + + return False def _handle_sasl_auth(self, data): try: @@ -375,18 +384,18 @@ class Client(threading.Thread): resource = saslauth[1] self.clientresource = resource authcode = saslauth[2] - - if authcode == "us_tempauthcode": #Handle auth + + if self.check_authcode(self.uid, authcode): #Send response self.send('') #Success #Client authenticated, move to next state self._set_state('INIT') - + else: #Failed to authenticate self.send('') #Fail - + except ET.ParseError as e: if "no element found" in e.msg: xmppserverlog.debug('xml parse error - {} - {} - this is common with ecovac protocol'.format(data.decode('utf-8'), e)) From 716a067be81a3abb7c97b2cb6f13edbdfe8505ad Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Sun, 17 Feb 2019 11:01:38 -0500 Subject: [PATCH 29/34] auth for mqtt and rename bot Add auth for mqtt and rename bot --- bumper.py | 6 +- bumper/__init__.py | 8 +++ bumper/confserver.py | 27 +++++--- bumper/mqttserver.py | 149 +++++++++++++++++++++++++++++++------------ bumper/xmppserver.py | 12 +--- 5 files changed, 139 insertions(+), 63 deletions(-) diff --git a/bumper.py b/bumper.py index 11aba3b..0f86cbc 100644 --- a/bumper.py +++ b/bumper.py @@ -31,7 +31,7 @@ def main(): mqtt_address = (listen_host, 8883) xmpp_server = bumper.XMPPServer(xmpp_address, bumper_users=bumper.bumper_users_var, bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var) - mqtt_server = bumper.MQTTServer(mqtt_address, bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var) + mqtt_server = bumper.MQTTServer(mqtt_address,bumper_users=bumper.bumper_users_var, bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var) mqtt_helperbot = bumper.MQTTHelperBot(mqtt_address, bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var) conf_server = bumper.ConfServer(conf_address_443, usessl=True,bumper_users=bumper.bumper_users_var, bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var,helperbot=mqtt_helperbot) conf_server_2 = bumper.ConfServer(conf_address_8007, usessl=False,bumper_users=bumper.bumper_users_var, bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var, helperbot=mqtt_helperbot) @@ -39,8 +39,8 @@ def main(): #add user users = bumper.bumper_users_var.get() user1 = bumper.BumperUser('user1') - user1.add_device('user_phone') - user1.add_bot('bot_id') + user1.add_device('devid') + user1.add_bot('bot_did') users.append(user1) bumper.bumper_users_var.set(users) diff --git a/bumper/__init__.py b/bumper/__init__.py index b39671c..c986fb1 100644 --- a/bumper/__init__.py +++ b/bumper/__init__.py @@ -99,3 +99,11 @@ class VacBotClient(object): def asdict(self): return {"userid": self.userid,"realm": self.realm,"resource": self.resource} + +def check_authcode(uid, authcode): + users = bumper_users_var.get() + for user in users: + if uid == "fuid_{}".format(user.userid) and authcode in user.authcodes: + return True + + return False \ No newline at end of file diff --git a/bumper/confserver.py b/bumper/confserver.py index ebebe5c..3ee906b 100644 --- a/bumper/confserver.py +++ b/bumper/confserver.py @@ -140,7 +140,7 @@ class ConfServer(): user_devid = request.match_info.get('devid', "") countrycode = request.match_info.get('country', "us") if not user_devid == "": #Performing basic "auth" using devid, super insecure - users = bumper.bumper_users_var.get() + users = self.bumper_users.get() for user in users: if user_devid in user.devices: tmpaccesstoken = '' @@ -163,8 +163,8 @@ class ConfServer(): }, "msg": "操作成功", "time": bumper.get_milli_time(time.time()) - } - bumper.bumper_users_var.set(users) + } + self.bumper_users.set(users) return web.json_response(body) body = { @@ -185,12 +185,12 @@ class ConfServer(): try: user_devid = request.match_info.get('devid', "") if not user_devid == "": - users = bumper.bumper_users_var.get() + users = self.bumper_users.get() for user in users: if user_devid in user.devices: if request.query['uid'] == "fuid_{}".format(user.userid) and request.query['accessToken'] in user.tokens: user.revoke_token(request.query['accessToken']) - bumper.bumper_users_var.set(users) + self.bumper_users.set(users) body = {"code": "0000","data": None,"msg": "操作成功", "time": bumper.get_milli_time(time.time())} @@ -204,7 +204,7 @@ class ConfServer(): user_devid = request.match_info.get('devid', "") if not user_devid == "": - users = bumper.bumper_users_var.get() + users = self.bumper_users.get() for user in users: if user_devid in user.devices and request.query['accessToken'] in user.tokens: countrycode = request.match_info.get('country', "us") @@ -220,7 +220,7 @@ class ConfServer(): "msg": "操作成功", "time": bumper.get_milli_time(time.time()) } - bumper.bumper_users_var.set(users) + self.bumper_users.set(users) return web.json_response(body) body = { @@ -323,7 +323,7 @@ class ConfServer(): body = {"result":"ok","ip":"47.88.66.164","port":8005} elif todo == 'loginByItToken': - users = bumper.bumper_users_var.get() + users = self.bumper_users.get() for user in users: if postbody['userId'] == "fuid_{}".format(user.userid) and postbody['token'] in user.authcodes: body = { @@ -342,6 +342,17 @@ class ConfServer(): "todo": "result" } + elif todo == 'SetDeviceNick': + bots = self.bumper_bots.get() + for bot in bots: + if postbody['did'] == bot['did']: + bot['nick'] = postbody['nick'] + self.bumper_bots.set(bots) + body = { + "result": "ok", + "todo": "result", + } + confserverlog.debug("\r\n POST: {} \r\n Response: {}".format(postbody,body)) return web.json_response(body) diff --git a/bumper/mqttserver.py b/bumper/mqttserver.py index e7ee992..b5213e9 100644 --- a/bumper/mqttserver.py +++ b/bumper/mqttserver.py @@ -148,9 +148,11 @@ class MQTTHelperBot(): class MQTTServer(): default_config = {} + bumper_users = [] bumper_clients = [] bumper_bots = [] + async def broker_coro(self): try: broker = hbmqtt.broker.Broker(config=self.default_config) @@ -174,9 +176,9 @@ class MQTTServer(): except Exception as e: mqttserverlog.exception('{}'.format(e)) - def __init__(self, address, bumper_bots=contextvars.ContextVar, bumper_clients=contextvars.ContextVar): + def __init__(self, address,bumper_users=contextvars.ContextVar, bumper_bots=contextvars.ContextVar, bumper_clients=contextvars.ContextVar): try: - + self.bumper_users = bumper_users self.bumper_bots = bumper_bots self.bumper_clients = bumper_clients self.mqttserverthread = None @@ -203,18 +205,19 @@ class MQTTServer(): }, 'sys_interval': 10, 'auth': { - 'allow-anonymous': True, + 'allow-anonymous': False, 'password-file': os.path.join(os.path.dirname(os.path.realpath(__file__)), "passwd"), 'plugins': [ - '' #No plugins == no auth + 'bumper' #No plugins == no auth ] }, 'topic-check': { 'enabled': False }, - 'clients':{ - 'connected_bots': self.bumper_bots, - 'connected_clients': self.bumper_clients, + 'bumper':{ + 'bumper_users' : self.bumper_users, + 'bumper_bots': self.bumper_bots, + 'bumper_clients': self.bumper_clients, }, } @@ -251,18 +254,79 @@ class BumperMQTTServer_Plugin: def __init__(self, context): self.context = context try: - self.clients = self.context.config['clients'] + self.bumper_config = self.context.config['bumper'] + self.auth_config = self.context.config['auth'] except KeyError: - self.context.logger.warning("'clients' section not found in context configuration") + self.context.logger.warning("'bumper' section not found in context configuration") except Exception as e: mqttserverlog.exception('{}'.format(e)) + + async def authenticate(self, *args, **kwargs): + if not self.auth_config: + # auth config section not found + self.context.logger.warning("'auth' section not found in context configuration") + return False + + allow_anonymous = self.auth_config.get('allow-anonymous', True) # allow anonymous by default + if allow_anonymous: + authenticated = True + self.context.logger.debug("Authentication success: config allows anonymous") + else: + try: + bumper_users = self.bumper_config['bumper_users'].get() + bumper_bots = self.bumper_config['bumper_bots'].get() + bumper_clients = self.bumper_config['bumper_clients'].get() + session = kwargs.get('session', None) + username = session.username + password = session.password + client_id = session.client_id + + didsplit = str(client_id).split("@") + #If this isn't a fake user (fuid) then add as a bot + if not (str(didsplit[0]).startswith("fuid") or str(didsplit[0]).startswith("helper")): + tmpbotdetail = str(didsplit[1]).split("/") + newbot = bumper.VacBotDevice() + newbot.did = didsplit[0] + newbot.name = username + newbot.vac_bot_device_class = tmpbotdetail[0] + newbot.resource = tmpbotdetail[1] + bumper_bots.append(newbot.asdict()) + mqttserverlog.info("new bot authenticated {}".format(newbot.asdict())) + self.bumper_config['bumper_bots'].set(bumper_bots) + authenticated = True + + else: + if didsplit[0] == "helper1": + authenticated = True + + elif bumper.check_authcode(didsplit[0], password): + tmpclientdetail = str(didsplit[1]).split("/") + newclient = bumper.VacBotClient() + newclient.userid = didsplit[0] + newclient.realm = tmpclientdetail[0] + newclient.resource = tmpclientdetail[1] + bumper_clients.append(newclient.asdict()) + mqttserverlog.info("new client authenticated {}".format(newclient.asdict())) + self.bumper_config['bumper_clients'].set(bumper_clients) + authenticated = True + + else: + authenticated = False + + except KeyError: + self.context.logger.warning("Session informations not available") + authenticated = False + return authenticated + + async def on_broker_client_connected(self, client_id): try: #mqttserverlog.debug('%s connected' % client_id) - connected_bots = self.clients['connected_bots'].get() - connected_clients = self.clients['connected_clients'].get() + bumper_users = self.bumper_config['bumper_users'].get() + bumper_bots = self.bumper_config['bumper_bots'].get() + bumper_clients = self.bumper_config['bumper_clients'].get() didsplit = str(client_id).split("@") #If this isn't a fake user (fuid) then add as a bot if not (str(didsplit[0]).startswith("fuid") or str(didsplit[0]).startswith("helper")): @@ -271,33 +335,34 @@ class BumperMQTTServer_Plugin: newbot.did = didsplit[0] newbot.vac_bot_device_class = tmpbotdetail[0] newbot.resource = tmpbotdetail[1] - botactive = False - for bot in connected_bots: - if bot['did'] == newbot.did: - botactive = True + #botactive = False + #for bot in bumper_bots: + # if bot['did'] == newbot.did: + # botactive = True - if botactive == False: - connected_bots.append(newbot.asdict()) - mqttserverlog.info("new bot {}".format(newbot.did)) + #if botactive == False: + # bumper_bots.append(newbot.asdict()) + mqttserverlog.debug("bot connected {}".format(newbot.did)) - self.clients['connected_bots'].set(connected_bots) + #self.bumper_config['bumper_bots'].set(bumper_bots) + else: - tmpuserdetail = str(didsplit[1]).split("/") - newuser = bumper.VacBotClient() - newuser.userid = didsplit[0] - newuser.realm = tmpuserdetail[0] - newuser.resource = tmpuserdetail[1] + tmpclientdetail = str(didsplit[1]).split("/") + newclient = bumper.VacBotClient() + newclient.userid = didsplit[0] + newclient.realm = tmpclientdetail[0] + newclient.resource = tmpclientdetail[1] + + #clientactive = False + #for client in bumper_clients: + # if client['userid'] == newuser.userid: + # clientactive = True - clientactive = False - for client in connected_clients: - if client['userid'] == newuser.userid: - clientactive = True + #if clientactive == False and newuser.userid != 'helper1': + #bumper_clients.append(newuser.asdict()) + mqttserverlog.debug("client connected {}".format(newclient.userid)) - if clientactive == False and newuser.userid != 'helper1': - connected_clients.append(newuser.asdict()) - mqttserverlog.info("new client {}".format(newuser.userid)) - - self.clients['connected_clients'].set(connected_clients) + #self.bumper['bumper_clients'].set(bumper_clients) #mqttserverlog.debug('Connected Bots: %s' %self.clients['connected_bots'].get()) #mqttserverlog.debug('Connected Clients: %s' %self.clients['connected_clients'].get()) @@ -306,28 +371,28 @@ class BumperMQTTServer_Plugin: mqttserverlog.exception('{}'.format(e)) - async def on_broker_client_disconnected(self, client_id): try: #mqttserverlog.debug('%s disconnected' % client_id) - connected_bots = self.clients['connected_bots'].get() - connected_clients = self.clients['connected_clients'].get() + bumper_users = self.bumper_config['bumper_users'].get() + bumper_bots = self.bumper_config['bumper_bots'].get() + bumper_clients = self.bumper_config['bumper_clients'].get() #remove_clients = self.clients['remove_clients'].get() didsplit = str(client_id).split("@") #If the did is in the list, remove it - for bot in connected_bots: + for bot in bumper_bots: if didsplit[0] == bot['did']: mqttserverlog.info("bot disconnected {}".format(bot['did'])) - connected_bots.remove(bot) + #bumper_bots.remove(bot) #remove_clients.append(bot['did']) - self.clients['connected_bots'].set(connected_bots) + #self.clients['bumper_bots'].set(bumper_bots) - for client in connected_clients: + for client in bumper_clients: if didsplit[0] == client['userid'] and client['userid'] != 'helper1': mqttserverlog.info("client disconnected {}".format(client['userid'])) - connected_clients.remove(client) + #bumper_clients.remove(client) #remove_clients.append(client['userid']) - self.clients['connected_clients'].set(connected_clients) + #self.bumper_config['bumper_clients'].set(bumper_clients) #self.clients['remove_clients'].set(remove_clients) diff --git a/bumper/xmppserver.py b/bumper/xmppserver.py index 4c1f800..3740c20 100644 --- a/bumper/xmppserver.py +++ b/bumper/xmppserver.py @@ -342,7 +342,7 @@ class Client(threading.Thread): elif 'resource' in aitem.tag: self.clientresource = aitem.text - if self.check_authcode(self.uid, password): + if bumper.check_authcode(self.uid, password): #Client authenticated, move to next state self._set_state('INIT') @@ -366,14 +366,6 @@ class Client(threading.Thread): except Exception as e: xmppserverlog.exception('{}'.format(e)) - def check_authcode(self, uid, authcode): - users = bumper.bumper_users_var.get() - for user in users: - if uid == "fuid_{}".format(user.userid) and authcode in user.authcodes: - return True - - return False - def _handle_sasl_auth(self, data): try: xml = ET.fromstring(data.decode('utf-8')) @@ -385,7 +377,7 @@ class Client(threading.Thread): self.clientresource = resource authcode = saslauth[2] - if self.check_authcode(self.uid, authcode): + if bumper.check_authcode(self.uid, authcode): #Send response self.send('') #Success From 3e88b0bfb11d47aa0852ddf9a1f302fe7158723f Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Sun, 17 Feb 2019 13:49:05 -0500 Subject: [PATCH 30/34] add api error codes add api error codes --- bumper/__init__.py | 42 ++++++++++++++++++++++++++++- bumper/confserver.py | 64 +++++++++++++++++++++++++++++--------------- 2 files changed, 83 insertions(+), 23 deletions(-) diff --git a/bumper/__init__.py b/bumper/__init__.py index c986fb1..9b84d69 100644 --- a/bumper/__init__.py +++ b/bumper/__init__.py @@ -106,4 +106,44 @@ def check_authcode(uid, authcode): if uid == "fuid_{}".format(user.userid) and authcode in user.authcodes: return True - return False \ No newline at end of file + return False + +RETURN_API_SUCCESS = "0000" +ERR_ACTIVATE_TOKEN_TIMEOUT = "1006" +ERR_COMMON = "0001" +ERR_DEFAULT = "9000" +ERR_EMAIL_NON_EXIST = "1002" +ERR_EMAIL_SEND_TIME_LIMIT = "1011" +ERR_EMAIL_USED = "1001" +ERR_INTERFACE_AUTH = "0002" +ERR_PARAM_INVALID = "0003" +ERR_PWD_WRONG = "1005" +ERR_RESET_PWD_TOKEN_TIMEOUT = "1007" +ERR_TIMESTAMP_INVALID = "0005" +ERR_TOKEN_INVALID = "0004" +ERR_USER_DISABLE = "1004" +ERR_USER_NOT_ACTIVATED = "1003" +ERR_WRONG_COMFIRM_PWD = "10010" +ERR_WRONG_EMAIL_ADDRESS = "1008" +ERR_WRONG_PWD_FROMATE = "1009" + +API_ERRORS = { + RETURN_API_SUCCESS: "0000", + ERR_ACTIVATE_TOKEN_TIMEOUT: "1006", + ERR_COMMON: "0001", + ERR_DEFAULT: "9000", + ERR_EMAIL_NON_EXIST: "1002", + ERR_EMAIL_SEND_TIME_LIMIT: "1011", + ERR_EMAIL_USED: "1001", + ERR_INTERFACE_AUTH: "0002", + ERR_PARAM_INVALID: "0003", + ERR_PWD_WRONG: "1005", + ERR_RESET_PWD_TOKEN_TIMEOUT: "1007", + ERR_TIMESTAMP_INVALID: "0005", + ERR_TOKEN_INVALID: "0004", + ERR_USER_DISABLE: "1004", + ERR_USER_NOT_ACTIVATED: "1003", + ERR_WRONG_COMFIRM_PWD: "10010", + ERR_WRONG_EMAIL_ADDRESS: "1008", + ERR_WRONG_PWD_FROMATE: "1009", +} diff --git a/bumper/confserver.py b/bumper/confserver.py index 3ee906b..c0e9cb1 100644 --- a/bumper/confserver.py +++ b/bumper/confserver.py @@ -146,29 +146,49 @@ class ConfServer(): tmpaccesstoken = '' if 'checkLogin' in request.path: if request.query['accessToken'] in user.tokens and request.query['uid'] == "fuid_{}".format(user.userid): - tmpaccesstoken = request.query['accessToken'] + tmpaccesstoken = request.query['accessToken'] + body = { + "code": bumper.RETURN_API_SUCCESS, + "data": { + "accessToken": tmpaccesstoken, #Random chars 32 length + "country": countrycode, + "email": "null@null.com", + "uid": "fuid_{}".format(user.userid), + "username": "fusername_{}".format(user.userid), + }, + "msg": "操作成功", + "time": bumper.get_milli_time(time.time()) + } + else: + body = { + "code": bumper.ERR_TOKEN_INVALID, + "data": None, + "msg": "当前密码错误", + "time": bumper.get_milli_time(time.time()) + } else: if tmpaccesstoken == '': tmpaccesstoken = uuid.uuid4().hex user.add_token(tmpaccesstoken) - body = { - "code": "0000", - "data": { - "accessToken": tmpaccesstoken, #Random chars 32 length - "country": countrycode, - "email": "null@null.com", - "uid": "fuid_{}".format(user.userid), - "username": "fusername_{}".format(user.userid), - }, - "msg": "操作成功", - "time": bumper.get_milli_time(time.time()) - } - self.bumper_users.set(users) + body = { + "code": bumper.RETURN_API_SUCCESS, + "data": { + "accessToken": tmpaccesstoken, #Random chars 32 length + "country": countrycode, + "email": "null@null.com", + "uid": "fuid_{}".format(user.userid), + "username": "fusername_{}".format(user.userid), + }, + "msg": "操作成功", + "time": bumper.get_milli_time(time.time()) + } + self.bumper_users.set(users) + return web.json_response(body) body = { - "code": "1005", + "code": bumper.ERR_USER_NOT_ACTIVATED, "data": None, "msg": "当前密码错误", "time": bumper.get_milli_time(time.time()) @@ -192,7 +212,7 @@ class ConfServer(): user.revoke_token(request.query['accessToken']) self.bumper_users.set(users) - body = {"code": "0000","data": None,"msg": "操作成功", "time": bumper.get_milli_time(time.time())} + body = {"code": bumper.RETURN_API_SUCCESS,"data": None,"msg": "操作成功", "time": bumper.get_milli_time(time.time())} return web.json_response(body) @@ -212,7 +232,7 @@ class ConfServer(): user.add_authcode(tmpauthcode) body = { - "code": "0000", + "code": bumper.RETURN_API_SUCCESS, "data": { "authCode": tmpauthcode, "ecovacsUid": request.query['uid'] @@ -224,7 +244,7 @@ class ConfServer(): return web.json_response(body) body = { - "code": "1005", + "code": bumper.ERR_TOKEN_INVALID, "data": None, "msg": "当前密码错误", "time": bumper.get_milli_time(time.time()) @@ -238,7 +258,7 @@ class ConfServer(): async def handle_checkVersion(self, request): try: body = { - "code": "0000", + "code": bumper.RETURN_API_SUCCESS, "data": { "c": None, "img": None, @@ -260,7 +280,7 @@ class ConfServer(): async def handle_checkAgreement(self, request): try: body = { - "code": "0000", + "code": bumper.RETURN_API_SUCCESS, "data": [], "msg": "操作成功", "time": bumper.get_milli_time(time.time()) @@ -277,7 +297,7 @@ class ConfServer(): nextAlert = bumper.get_milli_time((datetime.now() + timedelta(hours=12)).timestamp()) body = { - "code": "0000", + "code": bumper.RETURN_API_SUCCESS, "data": { "clickSchemeUrl": None, "clickWebUrl": None, @@ -297,7 +317,7 @@ class ConfServer(): async def handle_getProductIotMap(self, request): try: - body = {"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]} + body = {"code":bumper.RETURN_API_SUCCESS,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]} return web.json_response(body) except Exception as e: From d316862120b3accdb3b1e5b3d56a138313c75748 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Wed, 20 Feb 2019 00:00:06 -0500 Subject: [PATCH 31/34] Handle connected states --- bumper/__init__.py | 4 ++ bumper/confserver.py | 9 ++-- bumper/mqttserver.py | 108 +++++++++++++++++-------------------------- bumper/xmppserver.py | 47 ++++++++++++++++--- 4 files changed, 92 insertions(+), 76 deletions(-) diff --git a/bumper/__init__.py b/bumper/__init__.py index 9b84d69..45691ae 100644 --- a/bumper/__init__.py +++ b/bumper/__init__.py @@ -86,6 +86,8 @@ class VacBotDevice(object): self.name = name self.nick = nick self.resource = resource + self.mqtt_connection = False + self.xmpp_connection = False def asdict(self): return {"class": self.vac_bot_device_class, "company": self.company, @@ -96,6 +98,8 @@ class VacBotClient(object): self.userid = userid self.realm = realm self.resource = token + self.mqtt_connection = False + self.xmpp_connection = False def asdict(self): return {"userid": self.userid,"realm": self.realm,"resource": self.resource} diff --git a/bumper/confserver.py b/bumper/confserver.py index c0e9cb1..87726cb 100644 --- a/bumper/confserver.py +++ b/bumper/confserver.py @@ -356,8 +356,11 @@ class ConfServer(): elif todo == 'GetDeviceList': active_bots = self.bumper_bots.get() + bot_list = [] + for bot in active_bots: + bot_list.append(bot.asdict()) body = { - "devices": active_bots, + "devices": bot_list, "result": "ok", "todo": "result" } @@ -365,8 +368,8 @@ class ConfServer(): elif todo == 'SetDeviceNick': bots = self.bumper_bots.get() for bot in bots: - if postbody['did'] == bot['did']: - bot['nick'] = postbody['nick'] + if postbody['did'] == bot.did: + bot.nick = postbody['nick'] self.bumper_bots.set(bots) body = { "result": "ok", diff --git a/bumper/mqttserver.py b/bumper/mqttserver.py index b5213e9..d3246bf 100644 --- a/bumper/mqttserver.py +++ b/bumper/mqttserver.py @@ -225,7 +225,6 @@ class MQTTServer(): mqttserverlog.exception('{}'.format(e)) def run(self, run_async=False,): - if run_async: sloop = asyncio.new_event_loop() mqttserverlog.debug("Starting MQTTServer Thread: 1") @@ -236,7 +235,6 @@ class MQTTServer(): else: self.run_server() - def run_server(self, loop): logging.info("Starting MQTT Server at {}".format(self.address)) @@ -292,9 +290,16 @@ class BumperMQTTServer_Plugin: newbot.name = username newbot.vac_bot_device_class = tmpbotdetail[0] newbot.resource = tmpbotdetail[1] - bumper_bots.append(newbot.asdict()) - mqttserverlog.info("new bot authenticated {}".format(newbot.asdict())) - self.bumper_config['bumper_bots'].set(bumper_bots) + existingbot = False + for bot in bumper_bots: + if bot.did == newbot.did: + existingbot = True + + if existingbot == False: + bumper_bots.append(newbot) + mqttserverlog.info("new bot authenticated {}".format(newbot.name)) + self.bumper_config['bumper_bots'].set(bumper_bots) + authenticated = True else: @@ -307,9 +312,16 @@ class BumperMQTTServer_Plugin: newclient.userid = didsplit[0] newclient.realm = tmpclientdetail[0] newclient.resource = tmpclientdetail[1] - bumper_clients.append(newclient.asdict()) - mqttserverlog.info("new client authenticated {}".format(newclient.asdict())) - self.bumper_config['bumper_clients'].set(bumper_clients) + existingclient = False + for client in bumper_clients: + if client.userid == newclient.userid: + existingclient = True + + if existingclient == False: + bumper_clients.append(newclient) + mqttserverlog.info("new client authenticated {}".format(newclient.userid)) + self.bumper_config['bumper_clients'].set(bumper_clients) + authenticated = True else: @@ -323,81 +335,45 @@ class BumperMQTTServer_Plugin: async def on_broker_client_connected(self, client_id): try: - #mqttserverlog.debug('%s connected' % client_id) bumper_users = self.bumper_config['bumper_users'].get() - bumper_bots = self.bumper_config['bumper_bots'].get() - bumper_clients = self.bumper_config['bumper_clients'].get() + bumper_bots = self.bumper_config['bumper_bots'].get() + bumper_clients = self.bumper_config['bumper_clients'].get() didsplit = str(client_id).split("@") - #If this isn't a fake user (fuid) then add as a bot - if not (str(didsplit[0]).startswith("fuid") or str(didsplit[0]).startswith("helper")): - tmpbotdetail = str(didsplit[1]).split("/") - newbot = bumper.VacBotDevice() - newbot.did = didsplit[0] - newbot.vac_bot_device_class = tmpbotdetail[0] - newbot.resource = tmpbotdetail[1] - #botactive = False - #for bot in bumper_bots: - # if bot['did'] == newbot.did: - # botactive = True - - #if botactive == False: - # bumper_bots.append(newbot.asdict()) - mqttserverlog.debug("bot connected {}".format(newbot.did)) - - #self.bumper_config['bumper_bots'].set(bumper_bots) - else: - tmpclientdetail = str(didsplit[1]).split("/") - newclient = bumper.VacBotClient() - newclient.userid = didsplit[0] - newclient.realm = tmpclientdetail[0] - newclient.resource = tmpclientdetail[1] - - #clientactive = False - #for client in bumper_clients: - # if client['userid'] == newuser.userid: - # clientactive = True - - #if clientactive == False and newuser.userid != 'helper1': - #bumper_clients.append(newuser.asdict()) - mqttserverlog.debug("client connected {}".format(newclient.userid)) - - #self.bumper['bumper_clients'].set(bumper_clients) + for bot in bumper_bots: + if didsplit[0] == bot.did: + bot.mqtt_connection = True + mqttserverlog.info("bot connected {}".format(bot.did)) + self.bumper_config['bumper_bots'].set(bumper_bots) - #mqttserverlog.debug('Connected Bots: %s' %self.clients['connected_bots'].get()) - #mqttserverlog.debug('Connected Clients: %s' %self.clients['connected_clients'].get()) + for client in bumper_clients: + if didsplit[0] == client.userid and client.userid != 'helper1': + client.mqtt_connection = True + mqttserverlog.info("client connected {}".format(client.userid)) + self.bumper_config['bumper_clients'].set(bumper_clients) except Exception as e: mqttserverlog.exception('{}'.format(e)) async def on_broker_client_disconnected(self, client_id): - try: - #mqttserverlog.debug('%s disconnected' % client_id) + try: bumper_users = self.bumper_config['bumper_users'].get() bumper_bots = self.bumper_config['bumper_bots'].get() bumper_clients = self.bumper_config['bumper_clients'].get() - #remove_clients = self.clients['remove_clients'].get() didsplit = str(client_id).split("@") - #If the did is in the list, remove it + for bot in bumper_bots: - if didsplit[0] == bot['did']: - mqttserverlog.info("bot disconnected {}".format(bot['did'])) - #bumper_bots.remove(bot) - #remove_clients.append(bot['did']) - #self.clients['bumper_bots'].set(bumper_bots) + if didsplit[0] == bot.did: + bot.mqtt_connection = False + mqttserverlog.info("bot disconnected {}".format(bot.did)) + self.bumper_config['bumper_bots'].set(bumper_bots) for client in bumper_clients: - if didsplit[0] == client['userid'] and client['userid'] != 'helper1': - mqttserverlog.info("client disconnected {}".format(client['userid'])) - #bumper_clients.remove(client) - #remove_clients.append(client['userid']) - #self.bumper_config['bumper_clients'].set(bumper_clients) - - #self.clients['remove_clients'].set(remove_clients) - - #mqttserverlog.debug('Connected Bots: %s' %self.clients['connected_bots'].get()) - #mqttserverlog.debug('Connected Clients: %s' %self.clients['connected_clients'].get()) + if didsplit[0] == client.userid and client.userid != 'helper1': + client.mqtt_connection = False + mqttserverlog.info("client disconnected {}".format(client.userid)) + self.bumper_config['bumper_clients'].set(bumper_clients) except Exception as e: mqttserverlog.exception('{}'.format(e)) \ No newline at end of file diff --git a/bumper/xmppserver.py b/bumper/xmppserver.py index 3740c20..552da52 100644 --- a/bumper/xmppserver.py +++ b/bumper/xmppserver.py @@ -67,7 +67,7 @@ class XMPPServer(): xmppserverlog.debug('starting new client with ip {}'.format(client_address[0])) thread_id = uuid.uuid4() - client = Client(thread_id, connection, client_address) + client = Client(thread_id, connection, client_address, self.bumper_users, self.bumper_bots, self.bumper_clients) client.setDaemon(True) client.start() self.clients.append(client) @@ -138,7 +138,7 @@ class Client(threading.Thread): BOT = 1 CONTROLLER = 2 - def __init__(self, thread_id, connection, client_address): + def __init__(self, thread_id, connection, client_address,bumper_users=contextvars.ContextVar, bumper_bots=contextvars.ContextVar, bumper_clients=contextvars.ContextVar): threading.Thread.__init__(self) self.id = thread_id self.name = "XMPP_Client_{}".format(client_address[0]) @@ -150,6 +150,9 @@ class Client(threading.Thread): self.uid = "" self.log_sent_message = False #Set to true to log sends self.log_incoming_data = True #Set to true to log sends + self.bumper_users = bumper_users + self.bumper_bots = bumper_bots + self.bumper_clients = bumper_clients xmppserverlog.debug('new client thread init for client with ip {}'.format(self.address)) @@ -177,7 +180,22 @@ class Client(threading.Thread): def _disconnect(self): try: - xmppserverlog.debug('client {} with resource {} disconnecting'.format(self.address, self.clientresource)) + bumper_bots = self.bumper_bots.get() + bumper_clients = self.bumper_clients.get() + for bot in bumper_bots: + if self.uid == bot.did: + bot.xmpp_connection = False + xmppserverlog.info("bot disconnected {}".format(bot.did)) + + self.bumper_bots.set(bumper_bots) + + for client in bumper_clients: + if self.uid == client.userid and client.userid != 'helper1': + client.xmpp_connection = False + xmppserverlog.info("client disconnected {}".format(client.userid)) + + self.bumper_clients.set(bumper_clients) + #xmppserverlog.debug('client {} with resource {} disconnecting'.format(self.address, self.clientresource)) self.connection.close() except Exception as e: @@ -343,6 +361,22 @@ class Client(threading.Thread): self.clientresource = aitem.text if bumper.check_authcode(self.uid, password): + bumper_bots = self.bumper_bots.get() + bumper_clients = self.bumper_clients.get() + for bot in bumper_bots: + if self.uid == bot.did: + bot.xmpp_connection = True + xmppserverlog.info("bot connected {}".format(bot.did)) + + self.bumper_config['bumper_bots'].set(bumper_bots) + + for client in bumper_clients: + if self.uid == client.userid and client.userid != 'helper1': + client.xmpp_connection = True + xmppserverlog.info("client connected {}".format(client.userid)) + + self.bumper_config['bumper_clients'].set(bumper_clients) + #Client authenticated, move to next state self._set_state('INIT') @@ -351,9 +385,7 @@ class Client(threading.Thread): else: #Failed auth - self.send(''.format(xml.get('id'))) - - + self.send(''.format(xml.get('id'))) except ET.ParseError as e: if "no element found" in e.msg: @@ -377,7 +409,8 @@ class Client(threading.Thread): self.clientresource = resource authcode = saslauth[2] - if bumper.check_authcode(self.uid, authcode): + if bumper.check_authcode(self.uid, authcode): + #Send response self.send('') #Success From 0a5d93b0997d6fa84e3eef6c885f4561ff827ec5 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Wed, 20 Feb 2019 02:53:49 -0500 Subject: [PATCH 32/34] update auth mechanisms use_auth allows the newer auth mechanisms to be optional changes and fixes to support TODO: need to get a bot that would connect on XMPP or get sniffed traffic to see the login details --- README.md | 8 +- bumper.py | 13 ++- bumper/__init__.py | 51 +++++++++- bumper/confserver.py | 228 ++++++++++++++++++++++++++++--------------- bumper/mqttserver.py | 67 +++++-------- bumper/xmppserver.py | 105 ++++++++++++++------ 6 files changed, 306 insertions(+), 166 deletions(-) diff --git a/README.md b/README.md index 15c68b3..dbb8492 100644 --- a/README.md +++ b/README.md @@ -149,9 +149,13 @@ So far two protocols have been identified that various models of EcoVacs robots ---- ### Login/Authentication/RestAPI -***Bumper*** provides a fully simulated central server that handles login/authentication for the app/clients. At this time no authentication layer is implemented and you can use any e-mail/password when logging in. Future versions should add additional options here for security. +***Bumper*** provides a fully simulated central server that handles login/authentication for the app/clients. -This means however, that once a robot has been configured to access your WiFi it never needs to communicate with EcoVac's servers again. +The EcoVacs app encrypts the username/password with the public key of EcoVacs when authenticating. Since we don't have the private key to decrypt, there is no way to provide true security and authentication. + +Future versions may add additional options here for security. + +Bots have no authentication and once a robot has been configured to access your WiFi it never needs to communicate with EcoVac's servers again. ---- ### XMPP diff --git a/bumper.py b/bumper.py index 0f86cbc..be48215 100644 --- a/bumper.py +++ b/bumper.py @@ -37,13 +37,12 @@ def main(): conf_server_2 = bumper.ConfServer(conf_address_8007, usessl=False,bumper_users=bumper.bumper_users_var, bumper_bots=bumper.bumper_bots_var,bumper_clients=bumper.bumper_clients_var, helperbot=mqtt_helperbot) #add user - users = bumper.bumper_users_var.get() - user1 = bumper.BumperUser('user1') - user1.add_device('devid') - user1.add_bot('bot_did') - users.append(user1) - bumper.bumper_users_var.set(users) - + # users = bumper.bumper_users_var.get() + # user1 = bumper.BumperUser('user1') + # user1.add_device('devid') + # user1.add_bot('bot_did') + # users.append(user1) + # bumper.bumper_users_var.set(users) # start xmpp server on port 5223 (sync) xmpp_server.run(run_async=True) #Start in new thread diff --git a/bumper/__init__.py b/bumper/__init__.py index 45691ae..6ae059c 100644 --- a/bumper/__init__.py +++ b/bumper/__init__.py @@ -18,6 +18,8 @@ ca_cert = './certs/CA/cacert.pem' server_cert = './certs/cert.pem' server_key = './certs/key.pem' +use_auth = False + #Logs bumperlog = logging.getLogger("bumper") confserverlog = logging.getLogger("confserver") @@ -105,12 +107,51 @@ class VacBotClient(object): return {"userid": self.userid,"realm": self.realm,"resource": self.resource} def check_authcode(uid, authcode): - users = bumper_users_var.get() - for user in users: - if uid == "fuid_{}".format(user.userid) and authcode in user.authcodes: - return True + users = bumper_users_var.get() + for user in users: + if uid == "fuid_{}".format(user.userid) and authcode in user.authcodes: + return True + + return False + +def add_bot(sn, did, devclass, resource): + + newbot = VacBotDevice() + newbot.did = did + newbot.name = sn + newbot.vac_bot_device_class = devclass + newbot.resource = resource + + bots = bumper_bots_var.get() + existingbot = False + for bot in bots: + if bot.did == newbot.did: + existingbot = True + + if existingbot == False: + bots.append(newbot) + bumperlog.info("new bot added SN: {} DID: {}".format(newbot.name, newbot.did)) + bumper_bots_var.set(bots) + +def add_client(userid, realm, resource): + + newclient = VacBotClient() + newclient.userid = userid + newclient.realm = realm + newclient.resource = resource + + clients = bumper_clients_var.get() + + existingclient = False + for client in clients: + if client.userid == newclient.userid: + existingclient = True + + if existingclient == False: + clients.append(newclient) + bumperlog.info("new client added {}".format(newclient.userid)) + bumper_clients_var.set(clients) - return False RETURN_API_SUCCESS = "0000" ERR_ACTIVATE_TOKEN_TIMEOUT = "1006" diff --git a/bumper/confserver.py b/bumper/confserver.py index 87726cb..8f8d5db 100644 --- a/bumper/confserver.py +++ b/bumper/confserver.py @@ -134,73 +134,134 @@ class ConfServer(): except Exception as e: confserverlog.exception('{}'.format(e)) - async def handle_login(self, request): - + async def handle_login(self, request): try: user_devid = request.match_info.get('devid', "") countrycode = request.match_info.get('country', "us") - if not user_devid == "": #Performing basic "auth" using devid, super insecure - users = self.bumper_users.get() - for user in users: - if user_devid in user.devices: - tmpaccesstoken = '' - if 'checkLogin' in request.path: - if request.query['accessToken'] in user.tokens and request.query['uid'] == "fuid_{}".format(user.userid): - tmpaccesstoken = request.query['accessToken'] - body = { - "code": bumper.RETURN_API_SUCCESS, - "data": { - "accessToken": tmpaccesstoken, #Random chars 32 length - "country": countrycode, - "email": "null@null.com", - "uid": "fuid_{}".format(user.userid), - "username": "fusername_{}".format(user.userid), - }, - "msg": "操作成功", + confserverlog.info('client with devid {} attempting login'.format(user_devid)) + if bumper.use_auth: + if not user_devid == "": #Performing basic "auth" using devid, super insecure + users = self.bumper_users.get() + for user in users: + if user_devid in user.devices: + tmpaccesstoken = '' + if 'checkLogin' in request.path: + if request.query['accessToken'] in user.tokens and request.query['uid'] == "fuid_{}".format(user.userid): + tmpaccesstoken = request.query['accessToken'] + body = { + "code": bumper.RETURN_API_SUCCESS, + "data": { + "accessToken": tmpaccesstoken, #Random chars 32 length + "country": countrycode, + "email": "null@null.com", + "uid": "fuid_{}".format(user.userid), + "username": "fusername_{}".format(user.userid), + }, + "msg": "操作成功", + "time": bumper.get_milli_time(time.time()) + } + else: + body = { + "code": bumper.ERR_TOKEN_INVALID, + "data": None, + "msg": "当前密码错误", "time": bumper.get_milli_time(time.time()) - } + } else: + if tmpaccesstoken == '': + tmpaccesstoken = uuid.uuid4().hex + user.add_token(tmpaccesstoken) + body = { - "code": bumper.ERR_TOKEN_INVALID, - "data": None, - "msg": "当前密码错误", - "time": bumper.get_milli_time(time.time()) - } - else: - if tmpaccesstoken == '': - tmpaccesstoken = uuid.uuid4().hex - user.add_token(tmpaccesstoken) - - body = { - "code": bumper.RETURN_API_SUCCESS, - "data": { - "accessToken": tmpaccesstoken, #Random chars 32 length - "country": countrycode, - "email": "null@null.com", - "uid": "fuid_{}".format(user.userid), - "username": "fusername_{}".format(user.userid), - }, - "msg": "操作成功", - "time": bumper.get_milli_time(time.time()) - } - self.bumper_users.set(users) - - return web.json_response(body) - - body = { - "code": bumper.ERR_USER_NOT_ACTIVATED, - "data": None, - "msg": "当前密码错误", - "time": bumper.get_milli_time(time.time()) - } - - return web.json_response(body) - + "code": bumper.RETURN_API_SUCCESS, + "data": { + "accessToken": tmpaccesstoken, #Random chars 32 length + "country": countrycode, + "email": "null@null.com", + "uid": "fuid_{}".format(user.userid), + "username": "fusername_{}".format(user.userid), + }, + "msg": "操作成功", + "time": bumper.get_milli_time(time.time()) + } + self.bumper_users.set(users) + + return web.json_response(body) + + body = { + "code": bumper.ERR_USER_NOT_ACTIVATED, + "data": None, + "msg": "当前密码错误", + "time": bumper.get_milli_time(time.time()) + } + + return web.json_response(body) + else: + return web.json_response(self._auth_any(user_devid, countrycode, request)) except Exception as e: confserverlog.exception('{}'.format(e)) + def _auth_any(self, devid, country, request): + try: + user_devid = devid + countrycode = country + + tmpaccesstoken = '' + users = self.bumper_users.get() + bots = self.bumper_bots.get() + + if len(users) > 0: + tmpuser = users[0] + tmpuser.add_device(user_devid) + else: + tmpuser = bumper.BumperUser('tmpuser') + users.append(tmpuser) + tmpuser.add_device(user_devid) + + for bot in bots: + tmpuser.add_bot(bot.did) + + if 'checkLogin' in request.path: + tmpaccesstoken = request.query['accessToken'] + tmpuser.add_token(tmpaccesstoken) + body = { + "code": bumper.RETURN_API_SUCCESS, + "data": { + "accessToken": tmpaccesstoken, #Random chars 32 length + "country": countrycode, + "email": "null@null.com", + "uid": "fuid_{}".format(tmpuser.userid), + "username": "fusername_{}".format(tmpuser.userid), + }, + "msg": "操作成功", + "time": bumper.get_milli_time(time.time()) + } + else: + if tmpaccesstoken == '': + tmpaccesstoken = uuid.uuid4().hex + tmpuser.add_token(tmpaccesstoken) + + body = { + "code": bumper.RETURN_API_SUCCESS, + "data": { + "accessToken": tmpaccesstoken, #Random chars 32 length + "country": countrycode, + "email": "null@null.com", + "uid": "fuid_{}".format(tmpuser.userid), + "username": "fusername_{}".format(tmpuser.userid), + }, + "msg": "操作成功", + "time": bumper.get_milli_time(time.time()) + } + self.bumper_users.set(users) + + return body + + except Exception as e: + confserverlog.exception('{}'.format(e)) + async def handle_logout(self, request): try: user_devid = request.match_info.get('devid', "") @@ -225,30 +286,31 @@ class ConfServer(): user_devid = request.match_info.get('devid', "") if not user_devid == "": users = self.bumper_users.get() - for user in users: - if user_devid in user.devices and request.query['accessToken'] in user.tokens: - countrycode = request.match_info.get('country', "us") - tmpauthcode = "{}_{}".format(countrycode,uuid.uuid4().hex) - user.add_authcode(tmpauthcode) - - body = { - "code": bumper.RETURN_API_SUCCESS, - "data": { - "authCode": tmpauthcode, - "ecovacsUid": request.query['uid'] - }, - "msg": "操作成功", - "time": bumper.get_milli_time(time.time()) - } - self.bumper_users.set(users) - return web.json_response(body) + if len(users) > 0: + for user in users: + if user_devid in user.devices and request.query['accessToken'] in user.tokens: + countrycode = request.match_info.get('country', "us") + tmpauthcode = "{}_{}".format(countrycode,uuid.uuid4().hex) + user.add_authcode(tmpauthcode) + + body = { + "code": bumper.RETURN_API_SUCCESS, + "data": { + "authCode": tmpauthcode, + "ecovacsUid": request.query['uid'] + }, + "msg": "操作成功", + "time": bumper.get_milli_time(time.time()) + } + self.bumper_users.set(users) + return web.json_response(body) body = { "code": bumper.ERR_TOKEN_INVALID, "data": None, "msg": "当前密码错误", "time": bumper.get_milli_time(time.time()) - } + } return web.json_response(body) @@ -412,12 +474,18 @@ class ConfServer(): async def handle_devmanager_botcommand(self, request): try: json_body = json.loads(await request.text()) - randomid = ''.join(random.sample(string.ascii_letters,6)) - retcmd = await self.helperbot.send_command(json_body, randomid) - body = retcmd - - confserverlog.debug("\r\n POST: {} \r\n Response: {}".format(json_body,body)) - return web.json_response(body) + randomid = ''.join(random.sample(string.ascii_letters,6)) + bots = self.bumper_bots.get() + for bot in bots: + if bot.did == json_body['toId'] and bot.mqtt_connection == True: + retcmd = await self.helperbot.send_command(json_body, randomid) + body = retcmd + confserverlog.debug("\r\n POST: {} \r\n Response: {}".format(json_body,body)) + return web.json_response(body) + else: + confserverlog.error("No bots with DID: {} connected to MQTT".format(json_body['toId'])) + body = { "id": randomid, "errno": bumper.ERR_COMMON, "ret": "fail" } + return web.json_response(body) except Exception as e: confserverlog.exception('{}'.format(e)) diff --git a/bumper/mqttserver.py b/bumper/mqttserver.py index d3246bf..91e733c 100644 --- a/bumper/mqttserver.py +++ b/bumper/mqttserver.py @@ -284,51 +284,36 @@ class BumperMQTTServer_Plugin: didsplit = str(client_id).split("@") #If this isn't a fake user (fuid) then add as a bot if not (str(didsplit[0]).startswith("fuid") or str(didsplit[0]).startswith("helper")): - tmpbotdetail = str(didsplit[1]).split("/") - newbot = bumper.VacBotDevice() - newbot.did = didsplit[0] - newbot.name = username - newbot.vac_bot_device_class = tmpbotdetail[0] - newbot.resource = tmpbotdetail[1] - existingbot = False - for bot in bumper_bots: - if bot.did == newbot.did: - existingbot = True - - if existingbot == False: - bumper_bots.append(newbot) - mqttserverlog.info("new bot authenticated {}".format(newbot.name)) - self.bumper_config['bumper_bots'].set(bumper_bots) - + tmpbotdetail = str(didsplit[1]).split("/") + bumper.add_bot(username, didsplit[0], tmpbotdetail[0], tmpbotdetail[1]) + mqttserverlog.debug("new bot authenticated SN: {} DID: {}".format(username, didsplit[0])) authenticated = True else: - if didsplit[0] == "helper1": - authenticated = True + tmpclientdetail = str(didsplit[1]).split("/") + userid = didsplit[0] + realm = tmpclientdetail[0] + resource = tmpclientdetail[1] - elif bumper.check_authcode(didsplit[0], password): - tmpclientdetail = str(didsplit[1]).split("/") - newclient = bumper.VacBotClient() - newclient.userid = didsplit[0] - newclient.realm = tmpclientdetail[0] - newclient.resource = tmpclientdetail[1] - existingclient = False - for client in bumper_clients: - if client.userid == newclient.userid: - existingclient = True - - if existingclient == False: - bumper_clients.append(newclient) - mqttserverlog.info("new client authenticated {}".format(newclient.userid)) - self.bumper_config['bumper_clients'].set(bumper_clients) - + if userid == "helper1": authenticated = True - else: - authenticated = False + auth = False + if bumper.check_authcode(didsplit[0], password): + auth = True + elif bumper.use_auth == False: + auth = True + + if auth: + bumper.add_client(userid, realm, resource) + mqttserverlog.debug("client authenticated {}".format(userid)) + authenticated = True + + else: + authenticated = False except KeyError: - self.context.logger.warning("Session informations not available") + self.context.logger.warning("Session information not available") authenticated = False return authenticated @@ -343,13 +328,13 @@ class BumperMQTTServer_Plugin: for bot in bumper_bots: if didsplit[0] == bot.did: bot.mqtt_connection = True - mqttserverlog.info("bot connected {}".format(bot.did)) + #mqttserverlog.info("bot connected {}".format(bot.did)) self.bumper_config['bumper_bots'].set(bumper_bots) for client in bumper_clients: if didsplit[0] == client.userid and client.userid != 'helper1': client.mqtt_connection = True - mqttserverlog.info("client connected {}".format(client.userid)) + #mqttserverlog.info("client connected {}".format(client.userid)) self.bumper_config['bumper_clients'].set(bumper_clients) except Exception as e: @@ -366,13 +351,13 @@ class BumperMQTTServer_Plugin: for bot in bumper_bots: if didsplit[0] == bot.did: bot.mqtt_connection = False - mqttserverlog.info("bot disconnected {}".format(bot.did)) + #mqttserverlog.info("bot disconnected {}".format(bot.did)) self.bumper_config['bumper_bots'].set(bumper_bots) for client in bumper_clients: if didsplit[0] == client.userid and client.userid != 'helper1': client.mqtt_connection = False - mqttserverlog.info("client disconnected {}".format(client.userid)) + #mqttserverlog.info("client disconnected {}".format(client.userid)) self.bumper_config['bumper_clients'].set(bumper_clients) except Exception as e: diff --git a/bumper/xmppserver.py b/bumper/xmppserver.py index 552da52..a71b7c9 100644 --- a/bumper/xmppserver.py +++ b/bumper/xmppserver.py @@ -185,14 +185,14 @@ class Client(threading.Thread): for bot in bumper_bots: if self.uid == bot.did: bot.xmpp_connection = False - xmppserverlog.info("bot disconnected {}".format(bot.did)) + #xmppserverlog.info("bot disconnected {}".format(bot.did)) self.bumper_bots.set(bumper_bots) for client in bumper_clients: if self.uid == client.userid and client.userid != 'helper1': client.xmpp_connection = False - xmppserverlog.info("client disconnected {}".format(client.userid)) + #xmppserverlog.info("client disconnected {}".format(client.userid)) self.bumper_clients.set(bumper_clients) #xmppserverlog.debug('client {} with resource {} disconnecting'.format(self.address, self.clientresource)) @@ -356,37 +356,46 @@ class Client(threading.Thread): elif 'password' in aitem.tag: password = aitem.text.split("/")[2] + authcode = password elif 'resource' in aitem.tag: self.clientresource = aitem.text - - if bumper.check_authcode(self.uid, password): - bumper_bots = self.bumper_bots.get() - bumper_clients = self.bumper_clients.get() - for bot in bumper_bots: - if self.uid == bot.did: - bot.xmpp_connection = True - xmppserverlog.info("bot connected {}".format(bot.did)) - - self.bumper_config['bumper_bots'].set(bumper_bots) + resource = self.clientresource - for client in bumper_clients: - if self.uid == client.userid and client.userid != 'helper1': - client.xmpp_connection = True - xmppserverlog.info("client connected {}".format(client.userid)) + if not self.uid.startswith("fuid"): + + #Need sample data to see details here + bumper.add_bot('',self.uid, '', resource) + xmppserverlog.info("bot authenticated {}".format(self.uid)) - self.bumper_config['bumper_clients'].set(bumper_clients) - #Client authenticated, move to next state self._set_state('INIT') #Successful auth - self.send(''.format(xml.get('id'))) + self.send(''.format(xml.get('id'))) + + else: + auth = False + if bumper.check_authcode(self.uid, authcode): + auth = True + elif bumper.use_auth == False: + auth = True - else: - #Failed auth - self.send(''.format(xml.get('id'))) - + if auth: + bumper.add_client(self.uid, 'bumper', self.clientresource) + xmppserverlog.debug("client authenticated {}".format(self.uid)) + + #Client authenticated, move to next state + self._set_state('INIT') + + #Successful auth + self.send(''.format(xml.get('id'))) + + else: + #Failed auth + self.send(''.format(xml.get('id'))) + + except ET.ParseError as e: if "no element found" in e.msg: xmppserverlog.debug('xml parse error - {} - {} - this is common with ecovac protocol'.format(data.decode('utf-8'), e)) @@ -409,17 +418,36 @@ class Client(threading.Thread): self.clientresource = resource authcode = saslauth[2] - if bumper.check_authcode(self.uid, authcode): - + if not self.uid.startswith("fuid"): + #Need sample data to see details here + bumper.add_bot('',self.uid, '', resource) + xmppserverlog.info("bot authenticated {}".format(self.uid)) #Send response self.send('') #Success #Client authenticated, move to next state - self._set_state('INIT') + self._set_state('INIT') + + else: + auth = False + if bumper.check_authcode(self.uid, authcode): + auth = True + elif bumper.use_auth == False: + auth = True + + if auth: + bumper.add_client(self.uid, 'bumper', self.clientresource) + xmppserverlog.debug("client authenticated {}".format(self.uid)) - else: - #Failed to authenticate - self.send('') #Fail + #Client authenticated, move to next state + self._set_state('INIT') + + #Send response + self.send('') #Success + + else: + #Failed to authenticate + self.send('') #Fail except ET.ParseError as e: if "no element found" in e.msg: @@ -433,7 +461,22 @@ class Client(threading.Thread): xmppserverlog.exception('{}'.format(e)) def _handle_bind(self, xml): - try: + try: + bumper_bots = self.bumper_bots.get() + bumper_clients = self.bumper_clients.get() + + for bot in bumper_bots: + if self.uid == bot.did: + bot.xmpp_connection = True + #xmppserverlog.info("bot connected {}".format(bot.did)) + self.bumper_bots.set(bumper_bots) + + for client in bumper_clients: + if self.uid == client.userid: + client.xmpp_connection = True + #xmppserverlog.info("client connected {}".format(client.userid)) + self.bumper_clients.set(bumper_clients) + clientbindxml = xml.getchildren() clientresourcexml = clientbindxml[0].getchildren() if len(clientresourcexml) > 0: @@ -546,7 +589,7 @@ class Client(threading.Thread): def run(self): - xmppserverlog.info('client connected - {}'.format(self.address)) + #xmppserverlog.info('client connected - {}'.format(self.address)) self._set_state('CONNECT') while not self.state == self.DISCONNECT and not self.connection._closed: From e17a0cb30b7d3ca43fa02325475885e21eb069d4 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Wed, 20 Feb 2019 21:17:35 -0500 Subject: [PATCH 33/34] cleaning up formatting cleaning up formatting --- bumper.py | 4 +- bumper/__init__.py | 8 +- bumper/confserver.py | 168 +++++++++++++++--------------- bumper/mqttserver.py | 118 ++++++++++----------- bumper/xmppserver.py | 239 ++++++++++++++++++++++--------------------- 5 files changed, 268 insertions(+), 269 deletions(-) diff --git a/bumper.py b/bumper.py index be48215..c646c59 100644 --- a/bumper.py +++ b/bumper.py @@ -72,11 +72,11 @@ def main(): # if uid != "": # xmpp_server.remove_client_byuid(uid) #Remove clients from xmpp server # remove_clients.remove(uid) - + # bumper.bumper_removeclients_var.set(remove_clients) except KeyboardInterrupt: - bumper.bumperlog.info("Bumper Exiting - Keyboard Interrupt") + bumper.bumperlog.info("Bumper Exiting - Keyboard Interrupt") print("Bumper Exiting") exit(1) diff --git a/bumper/__init__.py b/bumper/__init__.py index 6ae059c..5e260f2 100644 --- a/bumper/__init__.py +++ b/bumper/__init__.py @@ -70,7 +70,7 @@ class BumperUser(object): def revoke_authcode(self, authcode): if authcode in self.authcodes: - self.authcodes.remove(authcode) + self.authcodes.remove(authcode) def add_bot(self, botdid): if not botdid in self.bots: @@ -78,7 +78,7 @@ class BumperUser(object): def remove_bot(self, botdid): if botdid in self.bots: - self.bots.remove(botdid) + self.bots.remove(botdid) class VacBotDevice(object): def __init__(self,did="", vac_bot_device_class="",resource="" , name="", nick="", company="eco-ng"): @@ -128,7 +128,7 @@ def add_bot(sn, did, devclass, resource): if bot.did == newbot.did: existingbot = True - if existingbot == False: + if existingbot == False: bots.append(newbot) bumperlog.info("new bot added SN: {} DID: {}".format(newbot.name, newbot.did)) bumper_bots_var.set(bots) @@ -141,7 +141,7 @@ def add_client(userid, realm, resource): newclient.resource = resource clients = bumper_clients_var.get() - + existingclient = False for client in clients: if client.userid == newclient.userid: diff --git a/bumper/confserver.py b/bumper/confserver.py index 8f8d5db..c3f76ba 100644 --- a/bumper/confserver.py +++ b/bumper/confserver.py @@ -17,7 +17,7 @@ class aiohttp_filter(logging.Filter): def filter(self, record): if record.name == "aiohttp.access" and record.levelno == 20: #Filters aiohttp.access log to switch it from INFO to DEBUG record.levelno = 10 - record.levelname = "DEBUG" + record.levelname = "DEBUG" if record.levelno == 10 and logging.getLogger("confserver").getEffectiveLevel() == 10: return True @@ -44,13 +44,13 @@ class ConfServer(): def run(self, run_async=False): - try: + try: if run_async: confserverlog.debug("Starting ConfServer Thread: 1") self.confthread = Thread(name="ConfServer_{}_Thread".format(self.address[1]),target=self.run_server) self.confthread.setDaemon(True) self.confthread.start() - + else: try: self.run_server() @@ -73,19 +73,19 @@ class ConfServer(): loop.run_until_complete(self.start_server()) loop.run_forever() except Exception as e: - confserverlog.exception('{}'.format(e)) - + confserverlog.exception('{}'.format(e)) + - async def start_server(self): + async def start_server(self): try: app = web.Application() - + app.add_routes([ web.get('', self.handle_base), web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/login', self.handle_login), - web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/checkLogin', self.handle_login), + web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/checkLogin', self.handle_login), web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/logout', self.handle_logout), - web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/getAuthCode', self.handle_getAuthCode), + web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/getAuthCode', self.handle_getAuthCode), web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/checkAgreement', self.handle_checkAgreement), web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/common/checkVersion', self.handle_checkVersion), web.get('/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/campaign/homePageAlert', self.handle_homePageAlert), @@ -94,22 +94,20 @@ class ConfServer(): web.get('/api/users/user.do', self.handle_usersapi), web.post('/api/pim/product/getProductIotMap', self.handle_getProductIotMap), web.post('/api/iot/devmanager.do', self.handle_devmanager_botcommand), - + web.post('/lookup.do', self.handle_lookup), ]) #Direct register from app: #/{apiversion}/private/{country}/{language}/{devid}/{apptype}/{appversion}/{devtype}/{aid}/user/directRegister - - runner = web.AppRunner(app) await runner.setup() - + if self.usessl: ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) ssl_ctx.load_cert_chain(bumper.server_cert,bumper.server_key) site = web.TCPSite(runner, host=self.address[0], port=self.address[1],ssl_context=ssl_ctx) - + else: site = web.TCPSite(runner, host=self.address[0], port=self.address[1]) @@ -121,18 +119,18 @@ class ConfServer(): exit(1) except Exception as e: - confserverlog.exception('{}'.format(e)) + confserverlog.exception('{}'.format(e)) exit(1) - async def handle_base(self, request): + async def handle_base(self, request): try: - + text = "Bumper!" - + return web.json_response(text) except Exception as e: - confserverlog.exception('{}'.format(e)) + confserverlog.exception('{}'.format(e)) async def handle_login(self, request): try: @@ -141,10 +139,10 @@ class ConfServer(): confserverlog.info('client with devid {} attempting login'.format(user_devid)) if bumper.use_auth: if not user_devid == "": #Performing basic "auth" using devid, super insecure - users = self.bumper_users.get() + users = self.bumper_users.get() for user in users: if user_devid in user.devices: - tmpaccesstoken = '' + tmpaccesstoken = '' if 'checkLogin' in request.path: if request.query['accessToken'] in user.tokens and request.query['uid'] == "fuid_{}".format(user.userid): tmpaccesstoken = request.query['accessToken'] @@ -166,12 +164,12 @@ class ConfServer(): "data": None, "msg": "当前密码错误", "time": bumper.get_milli_time(time.time()) - } + } else: if tmpaccesstoken == '': tmpaccesstoken = uuid.uuid4().hex user.add_token(tmpaccesstoken) - + body = { "code": bumper.RETURN_API_SUCCESS, "data": { @@ -184,38 +182,38 @@ class ConfServer(): "msg": "操作成功", "time": bumper.get_milli_time(time.time()) } - self.bumper_users.set(users) - + self.bumper_users.set(users) + return web.json_response(body) - + body = { "code": bumper.ERR_USER_NOT_ACTIVATED, "data": None, "msg": "当前密码错误", "time": bumper.get_milli_time(time.time()) - } - + } + return web.json_response(body) - else: - return web.json_response(self._auth_any(user_devid, countrycode, request)) + else: + return web.json_response(self._auth_any(user_devid, countrycode, request)) except Exception as e: - confserverlog.exception('{}'.format(e)) + confserverlog.exception('{}'.format(e)) def _auth_any(self, devid, country, request): try: user_devid = devid countrycode = country - - tmpaccesstoken = '' + + tmpaccesstoken = '' users = self.bumper_users.get() bots = self.bumper_bots.get() if len(users) > 0: tmpuser = users[0] tmpuser.add_device(user_devid) - else: + else: tmpuser = bumper.BumperUser('tmpuser') users.append(tmpuser) tmpuser.add_device(user_devid) @@ -223,7 +221,7 @@ class ConfServer(): for bot in bots: tmpuser.add_bot(bot.did) - if 'checkLogin' in request.path: + if 'checkLogin' in request.path: tmpaccesstoken = request.query['accessToken'] tmpuser.add_token(tmpaccesstoken) body = { @@ -242,7 +240,7 @@ class ConfServer(): if tmpaccesstoken == '': tmpaccesstoken = uuid.uuid4().hex tmpuser.add_token(tmpaccesstoken) - + body = { "code": bumper.RETURN_API_SUCCESS, "data": { @@ -255,44 +253,44 @@ class ConfServer(): "msg": "操作成功", "time": bumper.get_milli_time(time.time()) } - self.bumper_users.set(users) - + self.bumper_users.set(users) + return body except Exception as e: - confserverlog.exception('{}'.format(e)) + confserverlog.exception('{}'.format(e)) - async def handle_logout(self, request): - try: + async def handle_logout(self, request): + try: user_devid = request.match_info.get('devid', "") if not user_devid == "": users = self.bumper_users.get() for user in users: if user_devid in user.devices: - if request.query['uid'] == "fuid_{}".format(user.userid) and request.query['accessToken'] in user.tokens: + if request.query['uid'] == "fuid_{}".format(user.userid) and request.query['accessToken'] in user.tokens: user.revoke_token(request.query['accessToken']) - self.bumper_users.set(users) - - body = {"code": bumper.RETURN_API_SUCCESS,"data": None,"msg": "操作成功", "time": bumper.get_milli_time(time.time())} - + self.bumper_users.set(users) + + body = {"code": bumper.RETURN_API_SUCCESS,"data": None,"msg": "操作成功", "time": bumper.get_milli_time(time.time())} + return web.json_response(body) except Exception as e: - confserverlog.exception('{}'.format(e)) + confserverlog.exception('{}'.format(e)) - async def handle_getAuthCode(self, request): + async def handle_getAuthCode(self, request): try: - + user_devid = request.match_info.get('devid', "") if not user_devid == "": users = self.bumper_users.get() if len(users) > 0: for user in users: - if user_devid in user.devices and request.query['accessToken'] in user.tokens: + if user_devid in user.devices and request.query['accessToken'] in user.tokens: countrycode = request.match_info.get('country', "us") tmpauthcode = "{}_{}".format(countrycode,uuid.uuid4().hex) user.add_authcode(tmpauthcode) - + body = { "code": bumper.RETURN_API_SUCCESS, "data": { @@ -310,14 +308,14 @@ class ConfServer(): "data": None, "msg": "当前密码错误", "time": bumper.get_milli_time(time.time()) - } - + } + return web.json_response(body) except Exception as e: - confserverlog.exception('{}'.format(e)) + confserverlog.exception('{}'.format(e)) - async def handle_checkVersion(self, request): + async def handle_checkVersion(self, request): try: body = { "code": bumper.RETURN_API_SUCCESS, @@ -333,31 +331,31 @@ class ConfServer(): "msg": "操作成功", "time": bumper.get_milli_time(time.time()) } - - return web.json_response(body) + + return web.json_response(body) except Exception as e: - confserverlog.exception('{}'.format(e)) + confserverlog.exception('{}'.format(e)) - async def handle_checkAgreement(self, request): + async def handle_checkAgreement(self, request): try: body = { "code": bumper.RETURN_API_SUCCESS, "data": [], "msg": "操作成功", "time": bumper.get_milli_time(time.time()) - + } - + return web.json_response(body) except Exception as e: - confserverlog.exception('{}'.format(e)) + confserverlog.exception('{}'.format(e)) - async def handle_homePageAlert(self, request): + async def handle_homePageAlert(self, request): try: nextAlert = bumper.get_milli_time((datetime.now() + timedelta(hours=12)).timestamp()) - + body = { "code": bumper.RETURN_API_SUCCESS, "data": { @@ -371,19 +369,19 @@ class ConfServer(): "msg": "操作成功", "time": bumper.get_milli_time(time.time()) } - + return web.json_response(body) except Exception as e: - confserverlog.exception('{}'.format(e)) + confserverlog.exception('{}'.format(e)) - async def handle_getProductIotMap(self, request): + async def handle_getProductIotMap(self, request): try: body = {"code":bumper.RETURN_API_SUCCESS,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":False,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":True,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]} return web.json_response(body) except Exception as e: - confserverlog.exception('{}'.format(e)) + confserverlog.exception('{}'.format(e)) async def handle_usersapi(self, request): try: @@ -392,10 +390,10 @@ class ConfServer(): postbody = {} if request.content_type == "application/x-www-form-urlencoded": postbody = await request.post() - + else: - postbody = json.loads(await request.text()) - + postbody = json.loads(await request.text()) + todo = postbody['todo'] if todo == 'FindBest': service = postbody['service'] @@ -404,10 +402,10 @@ class ConfServer(): elif service == 'EcoUpdate': body = {"result":"ok","ip":"47.88.66.164","port":8005} elif todo == 'loginByItToken': - + users = self.bumper_users.get() for user in users: - if postbody['userId'] == "fuid_{}".format(user.userid) and postbody['token'] in user.authcodes: + if postbody['userId'] == "fuid_{}".format(user.userid) and postbody['token'] in user.authcodes: body = { "resource": postbody["resource"], "result": "ok", @@ -425,20 +423,20 @@ class ConfServer(): "devices": bot_list, "result": "ok", "todo": "result" - } + } elif todo == 'SetDeviceNick': bots = self.bumper_bots.get() for bot in bots: if postbody['did'] == bot.did: bot.nick = postbody['nick'] - self.bumper_bots.set(bots) + self.bumper_bots.set(bots) body = { "result": "ok", "todo": "result", } - confserverlog.debug("\r\n POST: {} \r\n Response: {}".format(postbody,body)) + confserverlog.debug("\r\n POST: {} \r\n Response: {}".format(postbody,body)) return web.json_response(body) except Exception as e: @@ -451,12 +449,12 @@ class ConfServer(): postbody = {} if request.content_type == "application/x-www-form-urlencoded": postbody = await request.post() - + else: postbody = json.loads(await request.text()) - + confserverlog.debug(postbody) - + todo = postbody['todo'] if todo == 'FindBest': service = postbody['service'] @@ -464,23 +462,23 @@ class ConfServer(): body = {"result":"ok","ip":socket.gethostbyname(socket.gethostname()),"port":5223} elif service == 'EcoUpdate': body = {"result":"ok","ip":"47.88.66.164","port":8005} - - confserverlog.debug("\r\n POST: {} \r\n Response: {}".format(postbody,body)) + + confserverlog.debug("\r\n POST: {} \r\n Response: {}".format(postbody,body)) return web.json_response(body) except Exception as e: - confserverlog.exception('{}'.format(e)) + confserverlog.exception('{}'.format(e)) async def handle_devmanager_botcommand(self, request): try: - json_body = json.loads(await request.text()) - randomid = ''.join(random.sample(string.ascii_letters,6)) + json_body = json.loads(await request.text()) + randomid = ''.join(random.sample(string.ascii_letters,6)) bots = self.bumper_bots.get() for bot in bots: if bot.did == json_body['toId'] and bot.mqtt_connection == True: retcmd = await self.helperbot.send_command(json_body, randomid) body = retcmd - confserverlog.debug("\r\n POST: {} \r\n Response: {}".format(json_body,body)) + confserverlog.debug("\r\n POST: {} \r\n Response: {}".format(json_body,body)) return web.json_response(body) else: confserverlog.error("No bots with DID: {} connected to MQTT".format(json_body['toId'])) diff --git a/bumper/mqttserver.py b/bumper/mqttserver.py index 91e733c..11bd4d0 100644 --- a/bumper/mqttserver.py +++ b/bumper/mqttserver.py @@ -37,12 +37,12 @@ class MQTTHelperBot(): def run(self, run_async=False): if run_async: - hloop = asyncio.new_event_loop() + hloop = asyncio.new_event_loop() helperbotlog.debug("Starting MQTT HelperBot Thread: 1") self.helperthread = Thread(name="MQTTHelperBot_Thread",target=self.run_helperbot, args=(hloop,)) self.helperthread.setDaemon(True) - self.helperthread.start() - + self.helperthread.start() + else: self.run_helperbot() @@ -51,13 +51,13 @@ class MQTTHelperBot(): logging.info("Starting MQTT HelperBot") print("Starting MQTT HelperBot") try: - asyncio.set_event_loop(loop) + asyncio.set_event_loop(loop) self.Client = MQTTClient(client_id=self.client_id, config={'check_hostname':False}) loop.run_until_complete(self.start_helper_bot()) loop.run_until_complete(self.get_msg()) loop.run_forever() except Exception as e: - helperbotlog.exception('{}'.format(e)) + helperbotlog.exception('{}'.format(e)) async def start_helper_bot(self): @@ -65,41 +65,41 @@ class MQTTHelperBot(): await self.Client.connect('mqtts://{}:{}/'.format(self.address[0], self.address[1]), cafile=bumper.ca_cert) await self.Client.subscribe([ ('iot/p2p/+/+/+/+/helper1/bumper/helper1/+/+/+',QOS_0), - ('iot/p2p/+',QOS_0) + ('iot/p2p/+',QOS_0) ]) except Exception as e: - helperbotlog.exception('{}'.format(e)) + helperbotlog.exception('{}'.format(e)) async def get_msg(self): try: while True: message = await self.Client.deliver_message() - + #helperbotlog.debug("HelperBot MQTT Received Message on Topic: {} - Message: {}".format(message.topic, str(message.payload.decode("utf-8")))) cresp = self.command_responses.get() - + if (str(message.topic).split("/")[6] == "helper1"): - cresp.append({"time": time.time() ,"topic": message.topic,"payload":str(message.data.decode("utf-8"))}) - + cresp.append({"time": time.time() ,"topic": message.topic,"payload":str(message.data.decode("utf-8"))}) + #Cleanup "expired messages" > 60 seconds from time for msg in cresp: expire_time = (datetime.fromtimestamp(msg['time']) + timedelta(seconds=10)).timestamp() if time.time() > expire_time: #helperbotlog.debug("Pruning Message Time: {}, MsgTime: {}, MsgTime+60: {}".format(time.time(), msg['time'], expire_time)) - cresp.remove(msg) - - self.command_responses.set(cresp) + cresp.remove(msg) + + self.command_responses.set(cresp) #helperbotlog.debug("MQTT Command Response List Count: %s" %len(cresp)) except Exception as e: helperbotlog.exception('{}'.format(e)) - async def wait_for_resp(self, requestid): + async def wait_for_resp(self, requestid): try: - + t_end = (datetime.now() + timedelta(seconds=10)).timestamp() - + while time.time() < t_end: await asyncio.sleep(0.1) responses = self.command_responses.get() @@ -110,7 +110,7 @@ class MQTTHelperBot(): #helperbotlog.debug('VacBot MQTT Response: Topic: %s Payload: %s' % (msg['topic'], msg['payload'])) if topic[11] == "j": resppayload = json.loads(msg['payload']) - else: + else: resppayload = str(msg['payload']) resp = { "id": requestid, @@ -118,28 +118,28 @@ class MQTTHelperBot(): "resp": resppayload } cresp = self.command_responses.get() - cresp.remove(msg) - self.command_responses.set(cresp) + cresp.remove(msg) + self.command_responses.set(cresp) return resp - + return { "id": requestid, "errno": "timeout", "ret": "fail" } except asyncio.CancelledError as e: helperbotlog.debug('wait_for_resp cancelled by asyncio') except Exception as e: - helperbotlog.exception('{}'.format(e)) + helperbotlog.exception('{}'.format(e)) async def send_command(self, cmdjson, requestid): try: ttopic = "iot/p2p/{}/helper1/bumper/helper1/{}/{}/{}/q/{}/{}".format(cmdjson["cmdName"], cmdjson["toId"], cmdjson["toType"], cmdjson["toRes"], requestid, cmdjson["payloadType"]) - try: + try: await self.Client.publish(ttopic, str(cmdjson["payload"]).encode(),QOS_0) except Exception as e: helperbotlog.exception("{}".format(e)) - + resp = await self.wait_for_resp(requestid) - + return resp except Exception as e: @@ -156,7 +156,7 @@ class MQTTServer(): async def broker_coro(self): try: broker = hbmqtt.broker.Broker(config=self.default_config) - await broker.start() + await broker.start() except PermissionError as e: if "bind" in e.strerror: @@ -174,7 +174,7 @@ class MQTTServer(): mqttserverlog.debug('connected bots - %s' % self.bumper_bots.get()) except Exception as e: - mqttserverlog.exception('{}'.format(e)) + mqttserverlog.exception('{}'.format(e)) def __init__(self, address,bumper_users=contextvars.ContextVar, bumper_bots=contextvars.ContextVar, bumper_clients=contextvars.ContextVar): try: @@ -186,7 +186,7 @@ class MQTTServer(): #The below adds a plugin to the hbmqtt.broker.plugins without having to futz with setup.py distribution = pkg_resources.Distribution("hbmqtt.broker.plugins") - bumper_plugin = pkg_resources.EntryPoint.parse('bumper = bumper.mqttserver:BumperMQTTServer_Plugin', dist=distribution) + bumper_plugin = pkg_resources.EntryPoint.parse('bumper = bumper.mqttserver:BumperMQTTServer_Plugin', dist=distribution) distribution._ep_map = {"hbmqtt.broker.plugins": {"bumper": bumper_plugin}} pkg_resources.working_set.add(distribution) @@ -218,22 +218,22 @@ class MQTTServer(): 'bumper_users' : self.bumper_users, 'bumper_bots': self.bumper_bots, 'bumper_clients': self.bumper_clients, - }, - } + }, + } except Exception as e: mqttserverlog.exception('{}'.format(e)) def run(self, run_async=False,): if run_async: - sloop = asyncio.new_event_loop() + sloop = asyncio.new_event_loop() mqttserverlog.debug("Starting MQTTServer Thread: 1") self.mqttserverthread = Thread(name="MQTTServer_Thread",target=self.run_server, args=(sloop,)) self.mqttserverthread.setDaemon(True) - self.mqttserverthread.start() - + self.mqttserverthread.start() + else: - self.run_server() + self.run_server() def run_server(self, loop): @@ -242,16 +242,16 @@ class MQTTServer(): try: asyncio.set_event_loop(loop) loop.run_until_complete(self.broker_coro()) - #loop.run_until_complete(self.active_bot_listing()) + #loop.run_until_complete(self.active_bot_listing()) loop.run_forever() except Exception as e: - mqttserverlog.exception('{}'.format(e)) + mqttserverlog.exception('{}'.format(e)) class BumperMQTTServer_Plugin: def __init__(self, context): self.context = context - try: + try: self.bumper_config = self.context.config['bumper'] self.auth_config = self.context.config['auth'] @@ -266,7 +266,7 @@ class BumperMQTTServer_Plugin: # auth config section not found self.context.logger.warning("'auth' section not found in context configuration") return False - + allow_anonymous = self.auth_config.get('allow-anonymous', True) # allow anonymous by default if allow_anonymous: authenticated = True @@ -280,21 +280,21 @@ class BumperMQTTServer_Plugin: username = session.username password = session.password client_id = session.client_id - + didsplit = str(client_id).split("@") #If this isn't a fake user (fuid) then add as a bot if not (str(didsplit[0]).startswith("fuid") or str(didsplit[0]).startswith("helper")): tmpbotdetail = str(didsplit[1]).split("/") bumper.add_bot(username, didsplit[0], tmpbotdetail[0], tmpbotdetail[1]) - mqttserverlog.debug("new bot authenticated SN: {} DID: {}".format(username, didsplit[0])) + mqttserverlog.debug("new bot authenticated SN: {} DID: {}".format(username, didsplit[0])) authenticated = True - + else: - tmpclientdetail = str(didsplit[1]).split("/") + tmpclientdetail = str(didsplit[1]).split("/") userid = didsplit[0] realm = tmpclientdetail[0] resource = tmpclientdetail[1] - + if userid == "helper1": authenticated = True else: @@ -306,17 +306,17 @@ class BumperMQTTServer_Plugin: if auth: bumper.add_client(userid, realm, resource) - mqttserverlog.debug("client authenticated {}".format(userid)) + mqttserverlog.debug("client authenticated {}".format(userid)) authenticated = True - + else: authenticated = False - - except KeyError: - self.context.logger.warning("Session information not available") - authenticated = False - return authenticated + except Exception as e: + mqttserverlog.exception('{}'.format(e)) + authenticated = False + + return authenticated async def on_broker_client_connected(self, client_id): try: @@ -324,40 +324,40 @@ class BumperMQTTServer_Plugin: bumper_bots = self.bumper_config['bumper_bots'].get() bumper_clients = self.bumper_config['bumper_clients'].get() didsplit = str(client_id).split("@") - + for bot in bumper_bots: if didsplit[0] == bot.did: bot.mqtt_connection = True - #mqttserverlog.info("bot connected {}".format(bot.did)) + mqttserverlog.debug("bot connected {}".format(bot.did)) self.bumper_config['bumper_bots'].set(bumper_bots) for client in bumper_clients: if didsplit[0] == client.userid and client.userid != 'helper1': client.mqtt_connection = True - #mqttserverlog.info("client connected {}".format(client.userid)) - self.bumper_config['bumper_clients'].set(bumper_clients) + #mqttserverlog.info("client connected {}".format(client.userid)) + self.bumper_config['bumper_clients'].set(bumper_clients) except Exception as e: - mqttserverlog.exception('{}'.format(e)) + mqttserverlog.exception('{}'.format(e)) async def on_broker_client_disconnected(self, client_id): - try: + try: bumper_users = self.bumper_config['bumper_users'].get() bumper_bots = self.bumper_config['bumper_bots'].get() bumper_clients = self.bumper_config['bumper_clients'].get() didsplit = str(client_id).split("@") - + for bot in bumper_bots: if didsplit[0] == bot.did: bot.mqtt_connection = False - #mqttserverlog.info("bot disconnected {}".format(bot.did)) + mqttserverlog.debug("bot disconnected {}".format(bot.did)) self.bumper_config['bumper_bots'].set(bumper_bots) for client in bumper_clients: if didsplit[0] == client.userid and client.userid != 'helper1': client.mqtt_connection = False - #mqttserverlog.info("client disconnected {}".format(client.userid)) + #mqttserverlog.info("client disconnected {}".format(client.userid)) self.bumper_config['bumper_clients'].set(bumper_clients) except Exception as e: diff --git a/bumper/xmppserver.py b/bumper/xmppserver.py index a71b7c9..e0a9aa7 100644 --- a/bumper/xmppserver.py +++ b/bumper/xmppserver.py @@ -27,10 +27,10 @@ class XMPPServer(): def run(self, run_async=False): if run_async: xmppserverlog.debug("Starting XMPPServer Thread: 1") - self.xmppthread = Thread(name="XMPPServer_Thread",target=self.run_server) + self.xmppthread = Thread(name="XMPPServer_Thread",target=self.run_server) self.xmppthread.setDaemon(True) self.xmppthread.start() - + else: try: self.run_server() @@ -44,39 +44,39 @@ class XMPPServer(): #xmppserverlog.setLevel(logging.DEBUG) #Set SSL Context - self.ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + self.ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) self.ssl_ctx.load_cert_chain(certfile=bumper.server_cert,keyfile=bumper.server_key) - self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: self.socket.bind(self.address) - self.socket.listen(5) + self.socket.listen(5) xmppserverlog.debug('listening on {}:{}'.format(self.address[0], self.address[1])) - while not self.exit_flag: + while not self.exit_flag: connection, client_address = self.socket.accept() - + # disconnect any clients with this ip for client in self.clients: - if client.address == client_address[0]: + if client.address == client_address[0]: xmppserverlog.debug('disconnecting existing client {} with resource {}'.format(client.address, client.clientresource)) client._disconnect() - self.remove_client_byip(client.address) - + self.remove_client_byip(client.address) + xmppserverlog.debug('starting new client with ip {}'.format(client_address[0])) - thread_id = uuid.uuid4() + thread_id = uuid.uuid4() client = Client(thread_id, connection, client_address, self.bumper_users, self.bumper_bots, self.bumper_clients) client.setDaemon(True) client.start() self.clients.append(client) - + except PermissionError as e: if "bind" in e.strerror: xmppserverlog.exception("Error binding XMPPServer, exiting. Try using a different hostname or IP - {}".format(e)) - exit(1) - + exit(1) + except Exception as e: xmppserverlog.exception('{}'.format(e)) exit(1) @@ -88,16 +88,16 @@ class XMPPServer(): connection.shutdown(socket.SHUT_RDWR) connection.close() self.disconnect() - xmppserverlog.info('disconnecting') + xmppserverlog.info('disconnecting') - self.socket.close() + self.socket.close() def disconnect(self): try: xmppserverlog.debug('waiting for all client threads to exit') for client in self.clients: client._disconnect() - + self.exit_flag = True xmppserverlog.debug('shutting down') @@ -172,11 +172,11 @@ class Client(threading.Thread): except ConnectionResetError as e: xmppserverlog.error('{}'.format(e)) - #self._set_state('DISCONNECT') + #self._set_state('DISCONNECT') except Exception as e: xmppserverlog.exception("{}".format(e)) - + def _disconnect(self): try: @@ -186,14 +186,14 @@ class Client(threading.Thread): if self.uid == bot.did: bot.xmpp_connection = False #xmppserverlog.info("bot disconnected {}".format(bot.did)) - + self.bumper_bots.set(bumper_bots) for client in bumper_clients: if self.uid == client.userid and client.userid != 'helper1': client.xmpp_connection = False - #xmppserverlog.info("client disconnected {}".format(client.userid)) - + #xmppserverlog.info("client disconnected {}".format(client.userid)) + self.bumper_clients.set(bumper_clients) #xmppserverlog.debug('client {} with resource {} disconnecting'.format(self.address, self.clientresource)) self.connection.close() @@ -215,29 +215,29 @@ class Client(threading.Thread): new_state = getattr(Client, state) if self.state > new_state: raise Exception('{} illegal state change {}->{}'.format(self.address, self.state, new_state)) - + xmppserverlog.debug('{} state: {}'.format(self.address, state)) - + self.state = new_state - + if new_state == 5: self._disconnect() except Exception as e: - xmppserverlog.exception("{}".format(e)) + xmppserverlog.exception("{}".format(e)) - def _handle_ctl(self, xml, data): + def _handle_ctl(self, xml, data): try: - if data.decode('utf-8').find('roster') > -1: - #Return not-implemented for roster + if data.decode('utf-8').find('roster') > -1: + #Return not-implemented for roster self.send(''.format(xml.get('id'))) return if xml.get('type') == 'set': if data.decode('utf-8').find('com:sf') > -1 and xml.get('to') == 'rl.ecorobot.net': #Android bind? Not sure what this does yet. self.send(''.format(xml.get('id'), self.uid, XMPPServer.bot_id, self.clientresource)) - + else: xmppserverlog.debug('Unknown set type: {}'.format(data.decode('utf-8'))) @@ -246,7 +246,7 @@ class Client(threading.Thread): if ctl.get('admin') and self.type == self.BOT: xmppserverlog.debug('admin username received from bot: {}'.format(ctl.get('admin'))) XMPPServer.client_id = ctl.get('admin') - return + return #forward for client in XMPPServer.clients: @@ -275,9 +275,9 @@ class Client(threading.Thread): client.send(data.decode('utf-8')) except Exception as e: - xmppserverlog.exception('{}'.format(e)) + xmppserverlog.exception('{}'.format(e)) - def _handle_result(self, data): + def _handle_result(self, data): # forward try: for client in XMPPServer.clients: @@ -286,11 +286,11 @@ class Client(threading.Thread): except Exception as e: xmppserverlog.exception("{}".format(e)) - + - def _handle_connect(self, data): + def _handle_connect(self, data): try: - + if self.state == self.CONNECT: #Client first connecting, send our features @@ -302,15 +302,15 @@ class Client(threading.Thread): #self.send(''.format(XMPPServer.server_id)) time.sleep(0.25) # send authentication support for iq-auth (fallback) and SASL - self.send('PLAIN') - #self.send('') - + self.send('PLAIN') + #self.send('') + elif data.decode('utf-8').find('jabber:iq:auth') > -1: #Handle iq-auth self._handle_iq_auth(data) elif data.decode('utf-8').find('urn:ietf:params:xml:ns:xmpp-sasl') > -1: #Handle SASL auth self._handle_sasl_auth(data) - + elif self.state == self.INIT: #Client getting session after authentication if data.decode('utf-8').find('jabber:client') > -1: @@ -318,34 +318,34 @@ class Client(threading.Thread): self.send(''.format(XMPPServer.server_id)) time.sleep(0.25) # session - self.send('') + self.send('') else: #Handle init bind - xml = ET.fromstring(data.decode('utf-8')) + xml = ET.fromstring(data.decode('utf-8')) if len(xml): child = self._tag_strip_uri(xml[0].tag) else: child = None - + if xml.tag == 'iq': if child == 'bind': self._handle_bind(xml) - + except Exception as e: xmppserverlog.exception('{}'.format(e)) - def _handle_iq_auth(self, data): + def _handle_iq_auth(self, data): try: xml = ET.fromstring(data.decode('utf-8')) - ctl = xml[0][0] + ctl = xml[0][0] xmppserverlog.info("IQ AUTH XML: {}".format(xml)) #Received username and auth tag, send username/password requirement - if xml.get('type') == 'get' and "auth}username" in ctl.tag and self.type == self.UNKNOWN: - self.send(''.format(xml.get('id'))) + if xml.get('type') == 'get' and "auth}username" in ctl.tag and self.type == self.UNKNOWN: + self.send(''.format(xml.get('id'))) #Received username, password, resource - Handle auth here and return pass or fail - if xml.get('type') == 'set' and "auth}username" in ctl.tag and self.type == self.UNKNOWN: + if xml.get('type') == 'set' and "auth}username" in ctl.tag and self.type == self.UNKNOWN: xmlauth = xml[0].getchildren() uid = '' password = '' @@ -353,7 +353,7 @@ class Client(threading.Thread): for aitem in xmlauth: if 'username' in aitem.tag: self.uid = aitem.text - + elif 'password' in aitem.tag: password = aitem.text.split("/")[2] authcode = password @@ -363,18 +363,18 @@ class Client(threading.Thread): resource = self.clientresource if not self.uid.startswith("fuid"): - + #Need sample data to see details here - bumper.add_bot('',self.uid, '', resource) + bumper.add_bot('',self.uid, '', resource) xmppserverlog.info("bot authenticated {}".format(self.uid)) - - #Client authenticated, move to next state + + #Client authenticated, move to next state self._set_state('INIT') - + #Successful auth - self.send(''.format(xml.get('id'))) - - else: + self.send(''.format(xml.get('id'))) + + else: auth = False if bumper.check_authcode(self.uid, authcode): auth = True @@ -382,34 +382,34 @@ class Client(threading.Thread): auth = True if auth: - bumper.add_client(self.uid, 'bumper', self.clientresource) - xmppserverlog.debug("client authenticated {}".format(self.uid)) - - #Client authenticated, move to next state + bumper.add_client(self.uid, 'bumper', self.clientresource) + xmppserverlog.debug("client authenticated {}".format(self.uid)) + + #Client authenticated, move to next state self._set_state('INIT') - + #Successful auth self.send(''.format(xml.get('id'))) - + else: #Failed auth self.send(''.format(xml.get('id'))) - - + + except ET.ParseError as e: if "no element found" in e.msg: - xmppserverlog.debug('xml parse error - {} - {} - this is common with ecovac protocol'.format(data.decode('utf-8'), e)) + xmppserverlog.debug('xml parse error - {} - {} - this is common with ecovac protocol'.format(data.decode('utf-8'), e)) elif "not well-formed (invalid token)" in e.msg: - xmppserverlog.debug('xml parse error - {} - {}'.format(data.decode('utf-8'), e)) + xmppserverlog.debug('xml parse error - {} - {}'.format(data.decode('utf-8'), e)) else: - xmppserverlog.debug('xml parse error - {} - {}'.format(data.decode('utf-8'), e)) + xmppserverlog.debug('xml parse error - {} - {}'.format(data.decode('utf-8'), e)) except Exception as e: xmppserverlog.exception('{}'.format(e)) - def _handle_sasl_auth(self, data): + def _handle_sasl_auth(self, data): try: - xml = ET.fromstring(data.decode('utf-8')) + xml = ET.fromstring(data.decode('utf-8')) saslauth = base64.b64decode(xml.text).decode('utf-8').split("/") username = saslauth[0] username = saslauth[0].split('\x00')[1] @@ -420,15 +420,15 @@ class Client(threading.Thread): if not self.uid.startswith("fuid"): #Need sample data to see details here - bumper.add_bot('',self.uid, '', resource) + bumper.add_bot('',self.uid, '', resource) xmppserverlog.info("bot authenticated {}".format(self.uid)) #Send response self.send('') #Success - #Client authenticated, move to next state + #Client authenticated, move to next state self._set_state('INIT') - - else: + + else: auth = False if bumper.check_authcode(self.uid, authcode): auth = True @@ -436,35 +436,35 @@ class Client(threading.Thread): auth = True if auth: - bumper.add_client(self.uid, 'bumper', self.clientresource) - xmppserverlog.debug("client authenticated {}".format(self.uid)) - - #Client authenticated, move to next state + bumper.add_client(self.uid, 'bumper', self.clientresource) + xmppserverlog.debug("client authenticated {}".format(self.uid)) + + #Client authenticated, move to next state self._set_state('INIT') #Send response self.send('') #Success - + else: #Failed to authenticate self.send('') #Fail - + except ET.ParseError as e: if "no element found" in e.msg: - xmppserverlog.debug('xml parse error - {} - {} - this is common with ecovac protocol'.format(data.decode('utf-8'), e)) + xmppserverlog.debug('xml parse error - {} - {} - this is common with ecovac protocol'.format(data.decode('utf-8'), e)) elif "not well-formed (invalid token)" in e.msg: - xmppserverlog.debug('xml parse error - {} - {}'.format(data.decode('utf-8'), e)) + xmppserverlog.debug('xml parse error - {} - {}'.format(data.decode('utf-8'), e)) else: - xmppserverlog.debug('xml parse error - {} - {}'.format(data.decode('utf-8'), e)) + xmppserverlog.debug('xml parse error - {} - {}'.format(data.decode('utf-8'), e)) except Exception as e: - xmppserverlog.exception('{}'.format(e)) + xmppserverlog.exception('{}'.format(e)) - def _handle_bind(self, xml): + def _handle_bind(self, xml): try: bumper_bots = self.bumper_bots.get() bumper_clients = self.bumper_clients.get() - + for bot in bumper_bots: if self.uid == bot.did: bot.xmpp_connection = True @@ -474,7 +474,7 @@ class Client(threading.Thread): for client in bumper_clients: if self.uid == client.userid: client.xmpp_connection = True - #xmppserverlog.info("client connected {}".format(client.userid)) + #xmppserverlog.info("client connected {}".format(client.userid)) self.bumper_clients.set(bumper_clients) clientbindxml = xml.getchildren() @@ -487,15 +487,15 @@ class Client(threading.Thread): else: xmppserverlog.debug("new client {}".format(self.address)) res = '{}'.format(xml.get('id'), XMPPServer.bot_id) - + self._set_state('BIND') self.send(res) except Exception as e: - xmppserverlog.exception('{}'.format(e)) + xmppserverlog.exception('{}'.format(e)) def _handle_session(self, xml): - try: + try: res = ''.format(xml.get('id')) self._set_state('READY') self.send(res) @@ -503,59 +503,60 @@ class Client(threading.Thread): except Exception as e: xmppserverlog.exception('{}'.format(e)) - def _handle_presence(self, xml): + def _handle_presence(self, xml): try: if len(xml) and xml[0].tag == 'status': # bot announcing arrival self.type = self.BOT xmppserverlog.debug('{} type set to BOT (based on presence tag)'.format(self.address)) # send a command from an unknown user - the response will contain the correct admin username - + self.send(''.format(uuid.uuid4(), 'unknown@ecouser.net', XMPPServer.bot_id)) - + else: self.type = self.CONTROLLER xmppserverlog.debug('{} type set to CONTROLLER (based on presence tag)'.format(self.address)) self.send(' dummy '.format(self.uid, XMPPServer.bot_id, self.clientresource)) - + except Exception as e: - xmppserverlog.exception('{}'.format(e)) + xmppserverlog.exception('{}'.format(e)) def _parse_data(self, data): if self.log_incoming_data: xmppserverlog.debug('from {} - {}'.format(self.address, data.decode('utf-8'))) - try: + try: xml = ET.fromstring(data.decode('utf-8')) self._handle_xml(xml, data) except ET.ParseError as e: - if "no element found" in e.msg: #Element not closed or not all bytes received + if "no element found" in e.msg: #Element not closed or not all bytes received #Happens wth connect stream often if ' - client is signalling end of session/disconnect if not '' in data.decode('utf-8'): xmppserverlog.error('xml parse error - {} - {}'.format(data.decode('utf-8'), e)) else: - self.send("") #Close stream + self.send("") #Close stream elif "junk after document element" in e.msg: #More than one xml doc in data #try to split it data0 = data.decode('utf-8') - data1 = data0[e.position[1]:] + data1 = data0[e.position[1]:] data0 = data0[:e.position[1]] #xmppserverlog.debug('xml parse error - {} - {} - split0: {} - split1: {}'.format(data.decode('utf-8'), e, data0, data1)) self._parse_data(data0.encode('utf-8')) self._parse_data(data1.encode('utf-8')) - + else: - xmppserverlog.debug('xml parse error - {} - {}'.format(data.decode('utf-8'), e)) + xmppserverlog.debug('xml parse error - {} - {}'.format(data.decode('utf-8'), e)) except Exception as e: xmppserverlog.exception('{}'.format(e)) @@ -563,29 +564,29 @@ class Client(threading.Thread): def _handle_xml(self, xml, data): try: if self.state == self.CONNECT or self.state == self.INIT: - self._handle_connect(data) + self._handle_connect(data) if len(xml): child = self._tag_strip_uri(xml[0].tag) else: child = None - + if xml.tag == 'iq': - if child == 'bind': + if child == 'bind': self._handle_bind(xml) - elif child == 'session': + elif child == 'session': self._handle_session(xml) - elif child == 'query': + elif child == 'query': self._handle_ctl(xml, data) elif child == 'ping': self._handle_ping(xml, data) - elif xml.get('type') == 'result': - self._handle_result(data) - elif xml.tag == 'presence': + elif xml.get('type') == 'result': + self._handle_result(data) + elif xml.tag == 'presence': self._handle_presence(xml) except Exception as e: - xmppserverlog.exception('{}'.format(e)) + xmppserverlog.exception('{}'.format(e)) def run(self): @@ -601,12 +602,12 @@ class Client(threading.Thread): data = self.connection.recv(4096) except ConnectionResetError as e: - xmppserverlog.error('{}'.format(e)) + xmppserverlog.error('{}'.format(e)) except OSError as e: - xmppserverlog.error('{}'.format(e)) + xmppserverlog.error('{}'.format(e)) except Exception as e: xmppserverlog.exception('{}'.format(e)) - - if data != b'': + + if data != b'': self._parse_data(data) - \ No newline at end of file + \ No newline at end of file From 3c9669c7e38908a24cd950270fdcafd9b45476c1 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Thu, 21 Feb 2019 23:31:21 -0500 Subject: [PATCH 34/34] bind add bind text --- bumper/xmppserver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bumper/xmppserver.py b/bumper/xmppserver.py index e0a9aa7..46a9ae1 100644 --- a/bumper/xmppserver.py +++ b/bumper/xmppserver.py @@ -486,7 +486,7 @@ class Client(threading.Thread): res = '{}@{}/{}'.format(xml.get('id'), self.uid, XMPPServer.bot_id, self.clientresource) else: xmppserverlog.debug("new client {}".format(self.address)) - res = '{}'.format(xml.get('id'), XMPPServer.bot_id) + res = '{}@{}'.format(xml.get('id'), self.uid, XMPPServer.bot_id) self._set_state('BIND') self.send(res)