Merge pull request #51 from edenhaus/proxyV2

add proxy mode
This commit is contained in:
Robert Resch 2022-08-20 19:41:20 +02:00 committed by GitHub
commit fbeca06694
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 564 additions and 115 deletions

View file

@ -3,9 +3,13 @@ import logging
import os import os
import socket import socket
import sys import sys
from typing import Any
from bumper.db import revoke_expired_oauths, revoke_expired_tokens from bumper.db import (
bot_reset_connectionStatus,
client_reset_connectionStatus,
revoke_expired_oauths,
revoke_expired_tokens,
)
from bumper.mqtt.helper_bot import HelperBot from bumper.mqtt.helper_bot import HelperBot
from bumper.mqtt.server import MQTTServer from bumper.mqtt.server import MQTTServer
from bumper.util import get_logger, log_to_stdout from bumper.util import get_logger, log_to_stdout
@ -50,6 +54,7 @@ bumper_debug = strtobool(os.environ.get("BUMPER_DEBUG")) or False
use_auth = False use_auth = False
token_validity_seconds = 3600 # 1 hour token_validity_seconds = 3600 # 1 hour
oauth_validity_days = 15 oauth_validity_days = 15
bumper_proxy_mode = strtobool(os.environ.get("BUMPER_PROXY_MODE")) or False
mqtt_server: MQTTServer mqtt_server: MQTTServer
mqtt_helperbot: HelperBot mqtt_helperbot: HelperBot
@ -60,6 +65,7 @@ shutting_down = False
bumperlog = get_logger("bumper") bumperlog = get_logger("bumper")
logging.getLogger("asyncio").setLevel(logging.CRITICAL + 1) # Ignore this logger logging.getLogger("asyncio").setLevel(logging.CRITICAL + 1) # Ignore this logger
proxymodelog = get_logger("proxymode")
web_server_https_port = os.environ.get("WEB_SERVER_HTTPS_PORT") or 443 web_server_https_port = os.environ.get("WEB_SERVER_HTTPS_PORT") or 443
mqtt_listen_port = 8883 mqtt_listen_port = 8883
@ -71,6 +77,10 @@ web_server_bindings = [
async def start() -> None: async def start() -> None:
# Reset xmpp/mqtt to false in database for bots and clients
bot_reset_connectionStatus()
client_reset_connectionStatus()
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
except: except:
@ -102,12 +112,16 @@ async def start() -> None:
return return
bumperlog.info("Starting Bumper") bumperlog.info("Starting Bumper")
if bumper_proxy_mode:
bumperlog.info("Proxy Mode Enabled")
global mqtt_server global mqtt_server
mqtt_server = MQTTServer(bumper_listen, mqtt_listen_port) mqtt_server = MQTTServer(bumper_listen, mqtt_listen_port)
global mqtt_helperbot global mqtt_helperbot
mqtt_helperbot = HelperBot(bumper_listen, mqtt_listen_port) mqtt_helperbot = HelperBot(bumper_listen, mqtt_listen_port)
global web_server global web_server
web_server = WebServer(web_server_bindings) web_server = WebServer(web_server_bindings, bumper_proxy_mode)
global xmpp_server global xmpp_server
xmpp_server = XMPPServer(bumper_listen, xmpp_listen_port) xmpp_server = XMPPServer(bumper_listen, xmpp_listen_port)
@ -194,12 +208,19 @@ def main(argv: None | list[str] = None) -> None:
help="announce address to bots on checkin", help="announce address to bots on checkin",
) )
parser.add_argument("--debug", action="store_true", help="enable debug logs") parser.add_argument("--debug", action="store_true", help="enable debug logs")
parser.add_argument(
"--proxy-mode", action="store_true", help="enable proxy mode"
)
args = parser.parse_args(args=argv) args = parser.parse_args(args=argv)
if args.debug: if args.debug:
bumper_debug = True bumper_debug = True
if args.proxy_mode:
global bumper_proxy_mode
bumper_proxy_mode = True
if args.listen: if args.listen:
bumper_listen = args.listen bumper_listen = args.listen

View file

@ -415,3 +415,17 @@ def client_set_xmpp(resource: str, xmpp: bool) -> None:
clients = db_get().table("clients") clients = db_get().table("clients")
Client = Query() Client = Query()
clients.upsert({"xmpp_connection": xmpp}, Client.resource == resource) clients.upsert({"xmpp_connection": xmpp}, Client.resource == resource)
def bot_reset_connectionStatus() -> None:
bots = db_get().table("bots")
for bot in bots:
bot_set_mqtt(bot["did"], False)
bot_set_xmpp(bot["did"], False)
def client_reset_connectionStatus() -> None:
clients = db_get().table("clients")
for client in clients:
client_set_mqtt(client["resource"], False)
client_set_xmpp(client["resource"], False)

11
bumper/dns.py Normal file
View file

@ -0,0 +1,11 @@
from aiohttp import AsyncResolver
def get_resolver_with_public_nameserver() -> AsyncResolver:
# requires aiodns
return AsyncResolver(nameservers=["1.1.1.1", "8.8.8.8"])
async def resolve(host: str) -> str:
hosts = await get_resolver_with_public_nameserver().resolve(host)
return hosts[0]["host"] # type:ignore[no-any-return]

View file

@ -35,6 +35,9 @@ class CommandDto:
self._event.set() self._event.set()
HELPER_BOT_CLIENT_ID = "helperbot@bumper/helperbot"
class HelperBot: class HelperBot:
"""Helper bot, which converts commands from the rest api to mqtt ones.""" """Helper bot, which converts commands from the rest api to mqtt ones."""
@ -44,9 +47,8 @@ class HelperBot:
) )
self._host = host self._host = host
self._port = port self._port = port
self._client_id = "helperbot@bumper/helperbot"
self._timeout = timeout self._timeout = timeout
self._client = Client("helperbot@bumper/helperbot") self._client = Client(HELPER_BOT_CLIENT_ID)
# pylint: disable=unused-argument # pylint: disable=unused-argument
async def _on_message( async def _on_message(
@ -150,6 +152,9 @@ class HelperBot:
finally: finally:
self._commands.pop(request_id, None) self._commands.pop(request_id, None)
def publish(self, topic: str, data: bytes) -> None:
self._client.publish(topic, data)
async def disconnect(self) -> None: async def disconnect(self) -> None:
"""Disconnect client.""" """Disconnect client."""
if self.is_connected: if self.is_connected:

222
bumper/mqtt/proxy.py Normal file
View file

@ -0,0 +1,222 @@
"""Mqtt proxy module."""
import asyncio
import re
import ssl
import typing
from typing import Any, MutableMapping
from urllib.parse import urlparse, urlunparse
import websockets
from amqtt.adapters import (
StreamReaderAdapter,
StreamWriterAdapter,
WebSocketsReader,
WebSocketsWriter,
)
from amqtt.client import ConnectException, MQTTClient
from amqtt.mqtt.connack import CONNECTION_ACCEPTED
from amqtt.mqtt.constants import QOS_0, QOS_1, QOS_2
from amqtt.mqtt.protocol.client_handler import ClientProtocolHandler
from amqtt.mqtt.protocol.handler import ProtocolHandlerException
from cachetools import TTLCache
from websockets.exceptions import InvalidHandshake, InvalidURI
import bumper
from ..util import get_logger
_LOGGER = get_logger("proxymode")
# iot/p2p/[command]]/[sender did]/[sender class]]/[sender resource]
# /[receiver did]/[receiver class]]/[receiver resource]/[q|p/[request id/j
# [q|p] q-> request p-> response
class ProxyClient:
"""Mqtt client, which proxies all messages to the ecovacs servers."""
def __init__(
self,
client_id: str,
host: str,
port: int = 443,
config: dict[str, Any] | None = None,
timeout: float = 180,
):
self.request_mapper: MutableMapping[str, str] = TTLCache(
maxsize=timeout * 60, ttl=timeout * 1.1
)
self._client = _NoCertVerifyClient(client_id=client_id, config=config)
self._host = host
self._port = port
async def connect(self, username: str, password: str) -> None:
try:
await self._client.connect(
f"mqtts://{username}:{password}@{self._host}:{self._port}"
)
except Exception:
_LOGGER.exception("An exception occurred during startup", exc_info=True)
raise
asyncio.create_task(self._handle_messages())
async def _handle_messages(self) -> None:
while self._client.session.transitions.is_connected():
try:
message = await self._client.deliver_message()
data = message.data.decode("utf-8") if message.data else ""
_LOGGER.info(
f"Message Received From Ecovacs - Topic: {message.topic} - Message: {data}"
)
topic = message.topic
ttopic = topic.split("/")
if ttopic[1] == "p2p":
if ttopic[3] == "proxyhelper":
_LOGGER.error(
f'"proxyhelper" was sender - INVALID!! Topic: {topic}'
)
continue
self.request_mapper[ttopic[10]] = ttopic[3]
ttopic[3] = "proxyhelper"
topic = "/".join(ttopic)
_LOGGER.info(f"Converted Topic From {message.topic} TO {topic}")
_LOGGER.info(
f"Proxy Forward Message to Robot - Topic: {topic} - Message: {data}"
)
bumper.mqtt_helperbot.publish(topic, message.data)
except Exception: # pylint: disable=broad-except
_LOGGER.error(
"An error occurred during handling a message", exc_info=True
)
async def subscribe(self, topic: str, qos: QOS_0 | QOS_1 | QOS_2 = QOS_0) -> None:
await self._client.subscribe([(topic, qos)])
async def disconnect(self) -> None:
await self._client.disconnect()
async def publish(self, topic: str, message: bytes, qos: int | None = None) -> None:
await self._client.publish(topic, message, qos)
class _NoCertVerifyClient(MQTTClient): # type:ignore[misc]
"""
Mqtt client, which is not verify the certificate.
Purpose is only to add "sc.verify_mode = ssl.CERT_NONE # Ignore verify of cert"
"""
@typing.no_type_check
async def _connect_coro(self):
kwargs = dict()
# Decode URI attributes
uri_attributes = urlparse(self.session.broker_uri)
scheme = uri_attributes.scheme
secure = True if scheme in ("mqtts", "wss") else False
self.session.username = (
self.session.username if self.session.username else uri_attributes.username
)
self.session.password = (
self.session.password if self.session.password else uri_attributes.password
)
self.session.remote_address = uri_attributes.hostname
self.session.remote_port = uri_attributes.port
if scheme in ("mqtt", "mqtts") and not self.session.remote_port:
self.session.remote_port = 8883 if scheme == "mqtts" else 1883
if scheme in ("ws", "wss") and not self.session.remote_port:
self.session.remote_port = 443 if scheme == "wss" else 80
if scheme in ("ws", "wss"):
# Rewrite URI to conform to https://tools.ietf.org/html/rfc6455#section-3
uri = (
scheme,
self.session.remote_address + ":" + str(self.session.remote_port),
uri_attributes[2],
uri_attributes[3],
uri_attributes[4],
uri_attributes[5],
)
self.session.broker_uri = urlunparse(uri)
# Init protocol handler
# if not self._handler:
self._handler = ClientProtocolHandler(self.plugins_manager)
if secure:
sc = ssl.create_default_context(
ssl.Purpose.SERVER_AUTH,
cafile=self.session.cafile,
capath=self.session.capath,
cadata=self.session.cadata,
)
if "certfile" in self.config and "keyfile" in self.config:
sc.load_cert_chain(self.config["certfile"], self.config["keyfile"])
if "check_hostname" in self.config and isinstance(
self.config["check_hostname"], bool
):
sc.check_hostname = self.config["check_hostname"]
sc.verify_mode = ssl.CERT_NONE # Ignore verify of cert
kwargs["ssl"] = sc
try:
reader = None
writer = None
self._connected_state.clear()
# Open connection
if scheme in ("mqtt", "mqtts"):
conn_reader, conn_writer = await asyncio.open_connection(
self.session.remote_address, self.session.remote_port, **kwargs
)
reader = StreamReaderAdapter(conn_reader)
writer = StreamWriterAdapter(conn_writer)
elif scheme in ("ws", "wss"):
websocket = await websockets.connect(
self.session.broker_uri,
subprotocols=["mqtt"],
extra_headers=self.extra_headers,
**kwargs,
)
reader = WebSocketsReader(websocket)
writer = WebSocketsWriter(websocket)
# Start MQTT protocol
self._handler.attach(self.session, reader, writer)
return_code = await self._handler.mqtt_connect()
if return_code is not CONNECTION_ACCEPTED:
self.session.transitions.disconnect()
self.logger.warning("Connection rejected with code '%s'" % return_code)
exc = ConnectException("Connection rejected by broker")
exc.return_code = return_code
raise exc
else:
# Handle MQTT protocol
await self._handler.start()
self.session.transitions.connect()
self._connected_state.set()
self.logger.debug(
"connected to %s:%s"
% (self.session.remote_address, self.session.remote_port)
)
return return_code
except InvalidURI as iuri:
self.logger.warning(
"connection failed: invalid URI '%s'" % self.session.broker_uri
)
self.session.transitions.disconnect()
raise ConnectException(
"connection failed: invalid URI '%s'" % self.session.broker_uri, iuri
)
except InvalidHandshake as ihs:
self.logger.warning("connection failed: invalid websocket handshake")
self.session.transitions.disconnect()
raise ConnectException(
"connection failed: invalid websocket handshake", ihs
)
except (ProtocolHandlerException, ConnectionError, OSError) as e:
self.logger.warning("MQTT connection failed: %r" % e)
self.session.transitions.disconnect()
raise ConnectException(e)

View file

@ -1,15 +1,16 @@
"""Server module.""" """Server module."""
import os import os
from typing import Any from typing import Any
import amqtt import amqtt
import pkg_resources import pkg_resources
from amqtt.broker import Broker, BrokerContext from amqtt.broker import Broker, BrokerContext
from amqtt.mqtt.constants import QOS_0, QOS_1, QOS_2
from amqtt.session import IncomingApplicationMessage, Session from amqtt.session import IncomingApplicationMessage, Session
from passlib.apps import custom_app_context as pwd_context from passlib.apps import custom_app_context as pwd_context
import bumper import bumper
from bumper import dns
from bumper.db import ( from bumper.db import (
bot_add, bot_add,
bot_get, bot_get,
@ -19,11 +20,14 @@ from bumper.db import (
client_get, client_get,
client_set_mqtt, client_set_mqtt,
) )
from bumper.mqtt.helper_bot import HELPER_BOT_CLIENT_ID
from bumper.mqtt.proxy import ProxyClient
from bumper.util import get_logger from bumper.util import get_logger
mqttserverlog = get_logger("mqttserver") mqttserverlog = get_logger("mqttserver")
helperbotlog = get_logger("helperbot") helperbotlog = get_logger("helperbot")
boterrorlog = get_logger("boterror") boterrorlog = get_logger("boterror")
proxymodelog = get_logger("proxymode")
class MQTTServer: class MQTTServer:
@ -122,6 +126,7 @@ class BumperMQTTServerPlugin:
"""MQTT Server plugin which handles the authentication.""" """MQTT Server plugin which handles the authentication."""
def __init__(self, context: BrokerContext) -> None: def __init__(self, context: BrokerContext) -> None:
self._proxy_clients: dict[str, ProxyClient] = {}
self.context = context self.context = context
try: try:
self.auth_config = self.context.config["auth"] self.auth_config = self.context.config["auth"]
@ -144,11 +149,14 @@ class BumperMQTTServerPlugin:
client_id = session.client_id client_id = session.client_id
try: try:
if client_id == HELPER_BOT_CLIENT_ID:
mqttserverlog.info("Bumper Authentication Success - Helperbot")
return True
if "@" in client_id: if "@" in client_id:
didsplit = str(client_id).split("@") didsplit = str(client_id).split("@")
if not ( # if ecouser or bumper aren't in details it is a bot if "ecouser" not in didsplit[1]:
"ecouser" in didsplit[1] or "bumper" in didsplit[1] # if ecouser aren't in details it is a bot
):
tmpbotdetail = str(didsplit[1]).split("/") tmpbotdetail = str(didsplit[1]).split("/")
bot_add( bot_add(
username, username,
@ -163,6 +171,18 @@ class BumperMQTTServerPlugin:
didsplit[0], didsplit[0],
tmpbotdetail[0], tmpbotdetail[0],
) )
if bumper.bumper_proxy_mode:
mqtt_server = await dns.resolve("mq-ww.ecouser.net")
proxymodelog.info(
f"MQTT Proxy Mode - Using server {mqtt_server} for client {client_id}"
)
proxy = ProxyClient(
client_id, mqtt_server, config={"check_hostname": False}
)
self._proxy_clients[client_id] = proxy
await proxy.connect(username, password)
return True return True
tmpclientdetail = str(didsplit[1]).split("/") tmpclientdetail = str(didsplit[1]).split("/")
@ -170,11 +190,6 @@ class BumperMQTTServerPlugin:
realm = tmpclientdetail[0] realm = tmpclientdetail[0]
resource = tmpclientdetail[1] resource = tmpclientdetail[1]
if userid == "helperbot":
mqttserverlog.info(
"Bumper Authentication Success - Helperbot: %s", client_id
)
return True
if check_authcode(didsplit[0], password) or not bumper.use_auth: if check_authcode(didsplit[0], password) or not bumper.use_auth:
client_add(userid, realm, resource) client_add(userid, realm, resource)
mqttserverlog.info( mqttserverlog.info(
@ -242,6 +257,21 @@ class BumperMQTTServerPlugin:
return users return users
async def on_broker_client_subscribed(
self, client_id: str, topic: str, qos: QOS_0 | QOS_1 | QOS_2
) -> None:
if bumper.bumper_proxy_mode:
# if proxy mode, also subscribe on ecovacs server
if client_id in self._proxy_clients:
await self._proxy_clients[client_id].subscribe(topic, qos)
proxymodelog.info(
f"MQTT Proxy Mode - New MQTT Topic Subscription - Client: {client_id} - Topic: {topic}"
)
elif client_id != HELPER_BOT_CLIENT_ID:
proxymodelog.warning(
f"MQTT Proxy Mode - No proxy client found! - Client: {client_id} - Topic: {topic}"
)
async def on_broker_client_connected(self, client_id: str) -> None: async def on_broker_client_connected(self, client_id: str) -> None:
"""On client connected.""" """On client connected."""
self._set_client_connected(client_id, True) self._set_client_connected(client_id, True)
@ -262,12 +292,13 @@ class BumperMQTTServerPlugin:
client_set_mqtt(client["resource"], connected) client_set_mqtt(client["resource"], connected)
async def on_broker_message_received( # pylint: disable=no-self-use async def on_broker_message_received( # pylint: disable=no-self-use
self, message: IncomingApplicationMessage, **_: dict[str, Any] self, message: IncomingApplicationMessage, client_id: str
) -> None: ) -> None:
"""On message received.""" """On message received."""
topic = message.topic topic = message.topic
topic_split = str(topic).split("/") topic_split = str(topic).split("/")
data_decoded = str(message.data.decode("utf-8")) data_decoded = str(message.data.decode("utf-8"))
if topic_split[6] == "helperbot": if topic_split[6] == "helperbot":
# Response to command # Response to command
_log__helperbot_message("Received Response", topic, data_decoded) _log__helperbot_message("Received Response", topic, data_decoded)
@ -285,6 +316,50 @@ class BumperMQTTServerPlugin:
else: else:
_log__helperbot_message("Received Message", topic, data_decoded) _log__helperbot_message("Received Message", topic, data_decoded)
if bumper.bumper_proxy_mode and client_id in self._proxy_clients:
if not topic_split[3] == "proxyhelper":
# if from proxyhelper, don't send back to ecovacs...yet
if topic_split[6] == "proxyhelper":
ttopic = message.topic.split("/")
ttopic[6] = self._proxy_clients[client_id].request_mapper.pop(
ttopic[10], ""
)
if ttopic[6] == "":
proxymodelog.warning(
"Request mapper is missing entry, "
f"probably request took to long... Client_id: {client_id}"
f" - Request_id: {ttopic[10]}"
)
return
ttopic_join = "/".join(ttopic)
proxymodelog.info(
f"Bot Message Converted Topic From {message.topic} TO {ttopic_join} "
f"with message: {data_decoded}"
)
else:
ttopic_join = message.topic
proxymodelog.info(
f"Bot Message From {ttopic_join} with message: {data_decoded}"
)
try:
# Send back to ecovacs
proxymodelog.info(
"Proxy Forward Message to Ecovacs - Topic:"
f" {ttopic_join} - Message: {data_decoded}"
)
await self._proxy_clients[client_id].publish(
ttopic_join, data_decoded.encode(), message.qos
)
except Exception: # pylint: disable=broad-except
proxymodelog.error(
"Forwarding to Ecovacs - Exception",
exc_info=True,
)
async def on_broker_client_disconnected(self, client_id: str) -> None: async def on_broker_client_disconnected(self, client_id: str) -> None:
"""On client disconnect.""" """On client disconnect."""
if bumper.bumper_proxy_mode and client_id in self._proxy_clients:
await self._proxy_clients.pop(client_id).disconnect()
self._set_client_connected(client_id, False) self._set_client_connected(client_id, False)

86
bumper/web/middlewares.py Normal file
View file

@ -0,0 +1,86 @@
"""Web server middleware module."""
import json
from typing import Any
from aiohttp import web
from aiohttp.typedefs import Handler
from aiohttp.web_exceptions import HTTPNoContent
from aiohttp.web_request import Request
from aiohttp.web_response import Response, StreamResponse
from bumper.util import get_logger
_LOGGER = get_logger("webserver_requests")
class CustomEncoder(json.JSONEncoder):
def default(self, obj: Any) -> Any:
if isinstance(obj, set):
return list(obj)
return json.JSONEncoder.default(self, obj)
_EXCLUDE_FROM_LOGGING = ["base", "remove-bot", "remove-client", "restart-service"]
@web.middleware
async def log_all_requests(request: Request, handler: Handler) -> StreamResponse:
if request.match_info.route.name not in _EXCLUDE_FROM_LOGGING:
to_log = {
"request": {
"method": request.method,
"url": str(request.url),
"path": request.path,
"query_string": request.query_string,
"headers": {h for h in request.headers.items()},
}
}
if request.match_info.route.resource:
to_log["request"][
"route_resource"
] = request.match_info.route.resource.canonical
try:
if request.content_length:
if request.content_type == "application/json":
to_log["request"]["body"] = await request.json()
else:
to_log["request"]["body"] = {h for h in await request.post()}
response = await handler(request)
if response is None:
_LOGGER.warning( # type:ignore[unreachable]
"Response was null!"
)
_LOGGER.warning(json.dumps(to_log, cls=CustomEncoder))
raise HTTPNoContent
to_log["response"] = {
"status": f"{response.status}",
}
if isinstance(response, Response) and response.body:
assert response.text
if response.content_type == "application/json":
to_log["response"]["body"] = json.loads(response.text)
elif response.content_type.startswith("text"):
to_log["response"]["body"] = response.text
return response
except web.HTTPNotFound:
_LOGGER.debug(f"Request path {request.raw_path} not found")
raise
except Exception:
_LOGGER.exception(
"An exception occurred in the logging middleware.", exc_info=True
)
raise
finally:
_LOGGER.debug(json.dumps(to_log, cls=CustomEncoder))
else:
return await handler(request)

View file

@ -1,5 +1,4 @@
"""Web server module.""" """Web server module."""
import asyncio import asyncio
import dataclasses import dataclasses
import json import json
@ -7,21 +6,19 @@ import logging
import os import os
import ssl import ssl
import aiohttp
import aiohttp_jinja2 import aiohttp_jinja2
import jinja2 import jinja2
from aiohttp import web from aiohttp import web
from aiohttp.typedefs import Handler from aiohttp.web_exceptions import HTTPInternalServerError
from aiohttp.web_exceptions import (
HTTPBadRequest,
HTTPInternalServerError,
HTTPNoContent,
)
from aiohttp.web_request import Request from aiohttp.web_request import Request
from aiohttp.web_response import Response, StreamResponse from aiohttp.web_response import Response
import bumper import bumper
from bumper.db import bot_get, bot_remove, client_get, client_remove, db_get from bumper.db import bot_get, bot_remove, client_get, client_remove, db_get
from bumper.dns import get_resolver_with_public_nameserver
from bumper.util import get_logger from bumper.util import get_logger
from bumper.web.middlewares import log_all_requests
from bumper.web.plugins import add_plugins from bumper.web.plugins import add_plugins
@ -41,6 +38,7 @@ class _aiohttp_filter(logging.Filter):
confserverlog = get_logger("confserver") confserverlog = get_logger("confserver")
# Add logging filter above to aiohttp.access # Add logging filter above to aiohttp.access
logging.getLogger("aiohttp.access").addFilter(_aiohttp_filter()) logging.getLogger("aiohttp.access").addFilter(_aiohttp_filter())
proxymodelog = logging.getLogger("proxymode")
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
@ -55,9 +53,9 @@ class WebserverBinding:
class WebServer: class WebServer:
"""Web server.""" """Web server."""
_EXCLUDE_FROM_LOGGING = ["base", "remove-bot", "remove-client", "restart-service"] def __init__(
self, bindings: list[WebserverBinding] | WebserverBinding, proxy_mode: bool
def __init__(self, bindings: list[WebserverBinding] | WebserverBinding): ):
self._runners: list[web.AppRunner] = [] self._runners: list[web.AppRunner] = []
if isinstance(bindings, WebserverBinding): if isinstance(bindings, WebserverBinding):
@ -66,7 +64,7 @@ class WebServer:
self._app = web.Application( self._app = web.Application(
middlewares=[ middlewares=[
self._log_all_requests, log_all_requests,
], ],
) )
aiohttp_jinja2.setup( aiohttp_jinja2.setup(
@ -75,32 +73,39 @@ class WebServer:
os.path.join(bumper.bumper_dir, "bumper", "web", "templates") os.path.join(bumper.bumper_dir, "bumper", "web", "templates")
), ),
) )
self._add_routes() self._add_routes(proxy_mode)
self._app.freeze() # no modification allowed anymore self._app.freeze() # no modification allowed anymore
def _add_routes(self) -> None: def _add_routes(self, proxy_mode: bool) -> None:
self._app.add_routes( self._app.add_routes(
[ [
web.get("", self._handle_base, name="base"), web.get("/bot/remove/{did}", self._handle_remove_bot),
web.get(
"/bot/remove/{did}", self._handle_remove_bot, name="remove-bot"
),
web.get( web.get(
"/client/remove/{resource}", "/client/remove/{resource}",
self._handle_remove_client, self._handle_remove_client,
name="remove-client",
), ),
web.get( web.get(
"/restart_{service}", "/restart_{service}",
self._handle_restart_service, self._handle_restart_service,
name="restart-service",
), ),
web.post("/lookup.do", self._handle_lookup),
web.post("/newauth.do", self._handle_newauth),
] ]
) )
add_plugins(self._app) if proxy_mode:
self._app.add_routes(
[
web.route("*", "/{path:.*}", self._handle_proxy),
]
)
else:
self._app.add_routes(
[
web.get("", self._handle_base),
web.post("/lookup.do", self._handle_lookup),
web.post("/newauth.do", self._handle_newauth),
]
)
add_plugins(self._app)
async def start(self) -> None: async def start(self) -> None:
"""Start server.""" """Start server."""
@ -173,75 +178,6 @@ class WebServer:
raise HTTPInternalServerError raise HTTPInternalServerError
@web.middleware
async def _log_all_requests(
self, request: Request, handler: Handler
) -> StreamResponse:
if request.match_info.route.name not in self._EXCLUDE_FROM_LOGGING:
to_log = {
"request": {
"route_name": f"{request.match_info.route.name}",
"method": f"{request.method}",
"path": f"{request.path}",
"query_string": f"{request.query_string}",
"raw_path": f"{request.raw_path}",
"raw_headers": f'{",".join(map("{}".format, request.raw_headers))}',
}
}
try:
postbody = None
if request.content_length:
if request.content_type == "application/x-www-form-urlencoded":
postbody = await request.post()
elif request.content_type == "application/json":
try:
postbody = json.loads(await request.text())
except Exception as e:
confserverlog.error(f"Request body not json: {e}")
raise HTTPBadRequest(reason="Body was not json")
else:
postbody = await request.post()
to_log["request"]["body"] = f"{postbody}"
response = await handler(request)
if response is None:
confserverlog.warning( # type:ignore[unreachable]
"Response was null!"
)
confserverlog.warning(json.dumps(to_log))
raise HTTPNoContent
to_log["response"] = {
"status": f"{response.status}",
}
if (
"application/octet-stream" not in response.content_type
and isinstance(response, Response)
and response.body
):
assert isinstance(response.body, bytes)
to_log["response"]["body"] = f"{json.loads(response.body)}"
confserverlog.debug(json.dumps(to_log))
return response
except web.HTTPNotFound as notfound:
confserverlog.debug(f"Request path {request.raw_path} not found")
confserverlog.debug(json.dumps(to_log))
return notfound
except Exception as e:
confserverlog.exception(f"{e}")
confserverlog.error(json.dumps(to_log))
raise e
else:
return await handler(request)
async def _restart_helper_bot(self) -> None: async def _restart_helper_bot(self) -> None:
await bumper.mqtt_helperbot.disconnect() await bumper.mqtt_helperbot.disconnect()
asyncio.create_task(bumper.mqtt_helperbot.start()) asyncio.create_task(bumper.mqtt_helperbot.start())
@ -365,3 +301,79 @@ class WebServer:
confserverlog.exception(f"{e}") confserverlog.exception(f"{e}")
raise HTTPInternalServerError raise HTTPInternalServerError
async def _handle_proxy(self, request: Request) -> Response:
try:
if request.raw_path == "/":
return await self._handle_base(request)
if request.raw_path == "/lookup.do":
return await self._handle_lookup(request)
# use bumper to handle lookup so bot gets Bumper IP and not Ecovacs
async with aiohttp.ClientSession(
headers=request.headers,
connector=aiohttp.TCPConnector(
verify_ssl=False, resolver=get_resolver_with_public_nameserver()
),
) as session:
if request.content.total_bytes > 0:
read_body = await request.read()
proxymodelog.info(
f"HTTP Proxy Request to EcoVacs (body=true) (URL:{request.url}) - {read_body.decode('utf-8')}"
)
if request.content_type == "application/x-www-form-urlencoded":
# android apps use form
fdata = await request.post()
async with session.request(
request.method, request.url, data=fdata
) as resp:
response = await resp.text()
proxymodelog.info(
f"HTTP Proxy Response from EcoVacs (URL: {request.url}) - (Status: {resp.status}) - {response}"
)
else:
# handle json
jdata = read_body.decode("utf8")
jdata = json.loads(jdata)
async with session.request(
request.method, request.url, json=jdata
) as resp:
response = await resp.text()
proxymodelog.info(
f"HTTP Proxy Response from EcoVacs (URL: {request.url}) - (Status: {resp.status}) - {response}"
)
else:
proxymodelog.info(
f"HTTP Proxy Request to EcoVacs (body=false) (URL:{request.url})"
)
async with session.request(request.method, request.url) as resp:
if resp.content_type == "application/octet-stream":
proxymodelog.info(
f"HTTP Proxy Response from EcoVacs (URL: {request.url}) - (Status: {resp.status}) - <BYTES CONTENT>"
)
return web.Response(body=await resp.read())
else:
response = await resp.text()
proxymodelog.info(
f"HTTP Proxy Response from EcoVacs (URL: {request.url}) - (Status: {resp.status}) - {response}"
)
if resp.status == 200:
if resp.content_type == "application/json":
response = json.loads(response)
return web.json_response(response)
if resp.content_type == "application/octet-stream":
return web.Response(body=response)
return web.Response(text=response)
except asyncio.CancelledError:
proxymodelog.exception(
f"Request cancelled or timeout - {request.url}", exc_info=True
)
raise
except Exception:
proxymodelog.exception("An exception occurred", exc_info=True)
raise HTTPInternalServerError

View file

@ -230,7 +230,7 @@ class XMPPAsyncClient:
xmppserverlog.debug( xmppserverlog.debug(
"admin username received from bot: {}".format(ctl.get("admin")) "admin username received from bot: {}".format(ctl.get("admin"))
) )
#XMPPServer.client_id = ctl.get("admin") # XMPPServer.client_id = ctl.get("admin")
return return
# forward # forward
@ -314,8 +314,8 @@ class XMPPAsyncClient:
xmppserverlog.info( xmppserverlog.info(
"Bot reported user has no permissions, Bumper will attempt to add user to bot. This is typical if bot was last on Ecovacs Network." "Bot reported user has no permissions, Bumper will attempt to add user to bot. This is typical if bot was last on Ecovacs Network."
) )
xquery = xml.getchildren() xquery = list(xml)
ctl = xquery[0].getchildren() ctl = list(xquery[0])
if "error" in ctl[0].attrib: if "error" in ctl[0].attrib:
ctlerr = ctl[0].attrib["error"] ctlerr = ctl[0].attrib["error"]
adminuser = ctlerr.replace( adminuser = ctlerr.replace(
@ -328,7 +328,8 @@ class XMPPAsyncClient:
adminuser.startswith("fuid_") adminuser.startswith("fuid_")
or adminuser.startswith("fusername_") or adminuser.startswith("fusername_")
or bumper.use_auth or bumper.use_auth
): # if not fuid_ then its ecovacs OR ignore bumper auth ):
# if not fuid_ then its ecovacs OR ignore bumper auth
# TODO: Implement auth later, should this user have access to bot? # TODO: Implement auth later, should this user have access to bot?
# Add user jid to bot # Add user jid to bot

View file

@ -1,3 +1,4 @@
aiodns==3.0.0
aiohttp==3.8.1 aiohttp==3.8.1
aiohttp-jinja2==1.5 aiohttp-jinja2==1.5
cachetools==5.2.0 cachetools==5.2.0
@ -5,3 +6,4 @@ git+https://github.com/Yakifo/amqtt@master#amqtt==11.0.0
gmqtt==0.6.11 gmqtt==0.6.11
Jinja2==3.1.2 Jinja2==3.1.2
tinydb==4.7.0 tinydb==4.7.0
websockets==10.3

View file

@ -54,7 +54,7 @@ async def helper_bot(mqtt_server: MQTTServer):
@pytest.fixture @pytest.fixture
async def webserver_client(aiohttp_client): async def webserver_client(aiohttp_client):
webserver = bumper.WebServer(WebserverBinding(HOST, WEBSERVER_PORT, False)) webserver = bumper.WebServer(WebserverBinding(HOST, WEBSERVER_PORT, False), False)
client = await aiohttp_client(webserver._app) client = await aiohttp_client(webserver._app)
yield client yield client

View file

@ -12,7 +12,7 @@ from tests import HOST, MQTT_PORT, WEBSERVER_PORT
def create_webserver(): def create_webserver():
return WebServer(WebserverBinding(HOST, WEBSERVER_PORT, False)) return WebServer(WebserverBinding(HOST, WEBSERVER_PORT, False), False)
def async_return(result): def async_return(result):
@ -27,12 +27,12 @@ def remove_existing_db():
async def test_webserver_ssl(): async def test_webserver_ssl():
webserver = WebServer(WebserverBinding(HOST, WEBSERVER_PORT, True)) webserver = WebServer(WebserverBinding(HOST, WEBSERVER_PORT, True), False)
await webserver.start() await webserver.start()
async def test_webserver_no_ssl(): async def test_webserver_no_ssl():
webserver = WebServer(WebserverBinding(HOST, 11112, False)) webserver = WebServer(WebserverBinding(HOST, 11112, False), False)
await webserver.start() await webserver.start()