diff --git a/Dockerfile b/Dockerfile index 601241a..7a97948 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,8 +34,10 @@ RUN cd basicswap-master; \ RUN useradd -ms /bin/bash swap_user && \ mkdir /coindata && chown swap_user -R /coindata -# Expose html port +# html port EXPOSE 12700 +# websocket port +EXPOSE 11700 VOLUME /coindata diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py index 034fc9e..caa53ef 100644 --- a/basicswap/basicswap.py +++ b/basicswap/basicswap.py @@ -129,7 +129,9 @@ from .basicswap_util import ( replaceAddrPrefix, getOfferProofOfFundsHash, getLastBidState, - isActiveBidState) + isActiveBidState, + NotificationTypes as NT, +) from .protocols.xmr_swap_1 import ( addLockRefundSigs, recoverNoScriptTxnWithKey) @@ -200,6 +202,8 @@ class WatchedTransaction(): class BasicSwap(BaseApp): + ws_server = None + def __init__(self, fp, data_dir, settings, chain, log_name='BasicSwap'): super().__init__(fp, data_dir, settings, chain, log_name) @@ -920,6 +924,25 @@ class BasicSwap(BaseApp): if (coin_from == Coins.PART_BLIND or coin_to == Coins.PART_BLIND) and swap_type != SwapTypes.XMR_SWAP: raise ValueError('Invalid swap type for PART_BLIND') + def notify(self, event_type, event_data): + if event_type == NT.OFFER_RECEIVED: + self.log.debug('Received new offer %s', event_data['offer_id']) + if self.ws_server: + event_data['event'] = 'new_offer' + self.ws_server.send_message_to_all(json.dumps(event_data)) + elif event_type == NT.BID_RECEIVED: + self.log.info('Received valid bid %s for %s offer %s', event_data['bid_id'], event_data['type'], event_data['offer_id']) + if self.ws_server: + event_data['event'] = 'new_bid' + self.ws_server.send_message_to_all(json.dumps(event_data)) + elif event_type == NT.BID_ACCEPTED: + self.log.info('Received valid bid accept for %s', event_data['bid_id']) + if self.ws_server: + event_data['event'] = 'bid_accepted' + self.ws_server.send_message_to_all(json.dumps(event_data)) + else: + self.log.warning(f'Unknown notification {event_type}') + def validateOfferAmounts(self, coin_from, coin_to, amount, rate, min_bid_amount): ci_from = self.ci(coin_from) ci_to = self.ci(coin_to) @@ -3625,6 +3648,8 @@ class BasicSwap(BaseApp): try: self.receiveXmrBidAccept(bid, session) except Exception as ex: + if self.debug: + self.log.error(traceback.format_exc()) self.log.info('Verify xmr bid accept {} failed: {}'.format(bid.bid_id.hex(), str(ex))) bid.setState(BidStates.BID_ERROR, 'Failed accept validation: ' + str(ex)) session.add(bid) @@ -3738,7 +3763,7 @@ class BasicSwap(BaseApp): session.add(xmr_offer) - self.log.debug('Received new offer %s', offer_id.hex()) + self.notify(NT.OFFER_RECEIVED, {'offer_id': offer_id.hex()}) else: existing_offer.setState(OfferStates.OFFER_RECEIVED) session.add(existing_offer) @@ -3972,8 +3997,8 @@ class BasicSwap(BaseApp): bid.setState(BidStates.BID_RECEIVED) - self.log.info('Received valid bid %s for offer %s', bid_id.hex(), bid_data.offer_msg_id.hex()) self.saveBid(bid_id, bid) + self.notify(NT.BID_RECEIVED, {'type': 'atomic', 'bid_id': bid_id.hex(), 'offer_id': bid_data.offer_msg_id.hex()}) if self.shouldAutoAcceptBid(offer, bid): delay = random.randrange(self.min_delay_event, self.max_delay_event) @@ -4048,10 +4073,11 @@ class BasicSwap(BaseApp): bid.setState(BidStates.BID_ACCEPTED) bid.setITxState(TxStates.TX_NONE) - self.log.info('Received valid bid accept %s for bid %s', bid.accept_msg_id.hex(), bid_id.hex()) + bid.offer_id.hex() self.saveBid(bid_id, bid) self.swaps_in_progress[bid_id] = (bid, offer) + self.notify(NT.BID_ACCEPTED, {'bid_id': bid_id.hex()}) def receiveXmrBid(self, bid, session): self.log.debug('Receiving xmr bid %s', bid.bid_id.hex()) @@ -4091,7 +4117,7 @@ class BasicSwap(BaseApp): ensure(ci_to.verifyKey(xmr_swap.vkbvf), 'Invalid key, vkbvf') ensure(ci_from.verifyPubkey(xmr_swap.pkaf), 'Invalid pubkey, pkaf') - self.log.info('Received valid bid %s for xmr offer %s', bid.bid_id.hex(), bid.offer_id.hex()) + self.notify(NT.BID_RECEIVED, {'type': 'xmr', 'bid_id': bid.bid_id.hex(), 'offer_id': bid.offer_id.hex()}) bid.setState(BidStates.BID_RECEIVED) @@ -4153,6 +4179,7 @@ class BasicSwap(BaseApp): bid.setState(BidStates.BID_ACCEPTED) # XMR self.saveBidInSession(bid.bid_id, bid, session, xmr_swap) + self.notify(NT.BID_ACCEPTED, {'bid_id': bid.bid_id.hex()}) delay = random.randrange(self.min_delay_event, self.max_delay_event) self.log.info('Responding to xmr bid accept %s in %d seconds', bid.bid_id.hex(), delay) diff --git a/basicswap/basicswap_util.py b/basicswap/basicswap_util.py index 1b3d588..71d44be 100644 --- a/basicswap/basicswap_util.py +++ b/basicswap/basicswap_util.py @@ -190,6 +190,13 @@ def strOfferState(state): return 'Unknown' +class NotificationTypes(IntEnum): + NONE = 0 + OFFER_RECEIVED = auto() + BID_RECEIVED = auto() + BID_ACCEPTED = auto() + + def strBidState(state): if state == BidStates.BID_SENT: return 'Sent' diff --git a/basicswap/contrib/websocket_server/__init__.py b/basicswap/contrib/websocket_server/__init__.py new file mode 100644 index 0000000..2a6bf45 --- /dev/null +++ b/basicswap/contrib/websocket_server/__init__.py @@ -0,0 +1 @@ +from .websocket_server import * diff --git a/basicswap/contrib/websocket_server/thread.py b/basicswap/contrib/websocket_server/thread.py new file mode 100644 index 0000000..a474203 --- /dev/null +++ b/basicswap/contrib/websocket_server/thread.py @@ -0,0 +1,38 @@ +import threading + + +class ThreadWithLoggedException(threading.Thread): + """ + Similar to Thread but will log exceptions to passed logger. + + Args: + logger: Logger instance used to log any exception in child thread + + Exception is also reachable via .exception from the main thread. + """ + + DIVIDER = "*"*80 + + def __init__(self, *args, **kwargs): + try: + self.logger = kwargs.pop("logger") + except KeyError: + raise Exception("Missing 'logger' in kwargs") + super().__init__(*args, **kwargs) + self.exception = None + + def run(self): + try: + if self._target is not None: + self._target(*self._args, **self._kwargs) + except Exception as exception: + thread = threading.current_thread() + self.exception = exception + self.logger.exception(f"{self.DIVIDER}\nException in child thread {thread}: {exception}\n{self.DIVIDER}") + finally: + del self._target, self._args, self._kwargs + + +class WebsocketServerThread(ThreadWithLoggedException): + """Dummy wrapper to make debug messages a bit more readable""" + pass diff --git a/basicswap/contrib/websocket_server/websocket_server.py b/basicswap/contrib/websocket_server/websocket_server.py new file mode 100644 index 0000000..75894b1 --- /dev/null +++ b/basicswap/contrib/websocket_server/websocket_server.py @@ -0,0 +1,495 @@ +# Author: Johan Hanssen Seferidis +# License: MIT + +import sys +import struct +import ssl +from base64 import b64encode +from hashlib import sha1 +import logging +from socket import error as SocketError +import errno +import threading +from socketserver import ThreadingMixIn, TCPServer, StreamRequestHandler + +from .thread import WebsocketServerThread + +logger = logging.getLogger(__name__) +logging.basicConfig() + +''' ++-+-+-+-+-------+-+-------------+-------------------------------+ + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 ++-+-+-+-+-------+-+-------------+-------------------------------+ +|F|R|R|R| opcode|M| Payload len | Extended payload length | +|I|S|S|S| (4) |A| (7) | (16/64) | +|N|V|V|V| |S| | (if payload len==126/127) | +| |1|2|3| |K| | | ++-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + +| Extended payload length continued, if payload len == 127 | ++ - - - - - - - - - - - - - - - +-------------------------------+ +| Payload Data continued ... | ++---------------------------------------------------------------+ +''' + +FIN = 0x80 +OPCODE = 0x0f +MASKED = 0x80 +PAYLOAD_LEN = 0x7f +PAYLOAD_LEN_EXT16 = 0x7e +PAYLOAD_LEN_EXT64 = 0x7f + +OPCODE_CONTINUATION = 0x0 +OPCODE_TEXT = 0x1 +OPCODE_BINARY = 0x2 +OPCODE_CLOSE_CONN = 0x8 +OPCODE_PING = 0x9 +OPCODE_PONG = 0xA + +CLOSE_STATUS_NORMAL = 1000 +DEFAULT_CLOSE_REASON = bytes('', encoding='utf-8') + + +class API(): + + def run_forever(self, threaded=False): + return self._run_forever(threaded) + + def new_client(self, client, server): + pass + + def client_left(self, client, server): + pass + + def message_received(self, client, server, message): + pass + + def set_fn_new_client(self, fn): + self.new_client = fn + + def set_fn_client_left(self, fn): + self.client_left = fn + + def set_fn_message_received(self, fn): + self.message_received = fn + + def send_message(self, client, msg): + self._unicast(client, msg) + + def send_message_to_all(self, msg): + self._multicast(msg) + + def deny_new_connections(self, status=CLOSE_STATUS_NORMAL, reason=DEFAULT_CLOSE_REASON): + self._deny_new_connections(status, reason) + + def allow_new_connections(self): + self._allow_new_connections() + + def shutdown_gracefully(self, status=CLOSE_STATUS_NORMAL, reason=DEFAULT_CLOSE_REASON): + self._shutdown_gracefully(status, reason) + + def shutdown_abruptly(self): + self._shutdown_abruptly() + + def disconnect_clients_gracefully(self, status=CLOSE_STATUS_NORMAL, reason=DEFAULT_CLOSE_REASON): + self._disconnect_clients_gracefully(status, reason) + + def disconnect_clients_abruptly(self): + self._disconnect_clients_abruptly() + + +class WebsocketServer(ThreadingMixIn, TCPServer, API): + """ + A websocket server waiting for clients to connect. + + Args: + port(int): Port to bind to + host(str): Hostname or IP to listen for connections. By default 127.0.0.1 + is being used. To accept connections from any client, you should use + 0.0.0.0. + loglevel: Logging level from logging module to use for logging. By default + warnings and errors are being logged. + + Properties: + clients(list): A list of connected clients. A client is a dictionary + like below. + { + 'id' : id, + 'handler' : handler, + 'address' : (addr, port) + } + """ + + allow_reuse_address = True + daemon_threads = True # comment to keep threads alive until finished + + def __init__(self, host='127.0.0.1', port=0, loglevel=logging.WARNING, key=None, cert=None): + logger.setLevel(loglevel) + TCPServer.__init__(self, (host, port), WebSocketHandler) + self.host = host + self.port = self.socket.getsockname()[1] + self.url = f'ws://{self.host}:{self.port}/' + + self.key = key + self.cert = cert + + self.clients = [] + self.id_counter = 0 + self.thread = None + + self._deny_clients = False + + def _run_forever(self, threaded): + cls_name = self.__class__.__name__ + try: + logger.info("Listening on port %d for clients.." % self.port) + if threaded: + self.daemon = True + self.thread = WebsocketServerThread(target=super().serve_forever, daemon=True, logger=logger) + if sys.version_info[0] > 3 or (sys.version_info[0] == 3 and sys.version_info[1] >= 10): + logger.info(f"Starting {cls_name} on thread {self.thread.name}.") + else: + logger.info(f"Starting {cls_name} on thread {self.thread.getName()}.") + self.thread.start() + else: + self.thread = threading.current_thread() + logger.info(f"Starting {cls_name} on main thread.") + super().serve_forever() + except KeyboardInterrupt: + self.server_close() + logger.info("Server terminated.") + except Exception as e: + logger.error(str(e), exc_info=True) + sys.exit(1) + + def _message_received_(self, handler, msg): + self.message_received(self.handler_to_client(handler), self, msg) + + def _ping_received_(self, handler, msg): + handler.send_pong(msg) + + def _pong_received_(self, handler, msg): + pass + + def _new_client_(self, handler): + if self._deny_clients: + status = self._deny_clients["status"] + reason = self._deny_clients["reason"] + handler.send_close(status, reason) + self._terminate_client_handler(handler) + return + + self.id_counter += 1 + client = { + 'id': self.id_counter, + 'handler': handler, + 'address': handler.client_address + } + self.clients.append(client) + self.new_client(client, self) + + def _client_left_(self, handler): + client = self.handler_to_client(handler) + self.client_left(client, self) + if client in self.clients: + self.clients.remove(client) + + def _unicast(self, receiver_client, msg): + receiver_client['handler'].send_message(msg) + + def _multicast(self, msg): + for client in self.clients: + self._unicast(client, msg) + + def handler_to_client(self, handler): + for client in self.clients: + if client['handler'] == handler: + return client + + def _terminate_client_handler(self, handler): + handler.keep_alive = False + handler.finish() + handler.connection.close() + + def _terminate_client_handlers(self): + """ + Ensures request handler for each client is terminated correctly + """ + for client in self.clients: + self._terminate_client_handler(client["handler"]) + + def _shutdown_gracefully(self, status=CLOSE_STATUS_NORMAL, reason=DEFAULT_CLOSE_REASON): + """ + Send a CLOSE handshake to all connected clients before terminating server + """ + self.keep_alive = False + self._disconnect_clients_gracefully(status, reason) + self.server_close() + self.shutdown() + + def _shutdown_abruptly(self): + """ + Terminate server without sending a CLOSE handshake + """ + self.keep_alive = False + self._disconnect_clients_abruptly() + self.server_close() + self.shutdown() + + def _disconnect_clients_gracefully(self, status=CLOSE_STATUS_NORMAL, reason=DEFAULT_CLOSE_REASON): + """ + Terminate clients gracefully without shutting down the server + """ + for client in self.clients: + client["handler"].send_close(status, reason) + self._terminate_client_handlers() + + def _disconnect_clients_abruptly(self): + """ + Terminate clients abruptly (no CLOSE handshake) without shutting down the server + """ + self._terminate_client_handlers() + + def _deny_new_connections(self, status, reason): + self._deny_clients = { + "status": status, + "reason": reason, + } + + def _allow_new_connections(self): + self._deny_clients = False + + +class WebSocketHandler(StreamRequestHandler): + + def __init__(self, socket, addr, server): + self.server = server + self.timeout = 1000 # Must set a timeout or rfile.read timesout in the tests + assert not hasattr(self, "_send_lock"), "_send_lock already exists" + self._send_lock = threading.Lock() + if server.key and server.cert: + try: + socket = ssl.wrap_socket(socket, server_side=True, certfile=server.cert, keyfile=server.key) + except: # Not sure which exception it throws if the key/cert isn't found + logger.warning("SSL not available (are the paths {} and {} correct for the key and cert?)".format(server.key, server.cert)) + StreamRequestHandler.__init__(self, socket, addr, server) + + def setup(self): + StreamRequestHandler.setup(self) + self.keep_alive = True + self.handshake_done = False + self.valid_client = False + + def handle(self): + while self.keep_alive: + if not self.handshake_done: + self.handshake() + elif self.valid_client: + self.read_next_message() + + def read_bytes(self, num): + return self.rfile.read(num) + + def read_next_message(self): + try: + b1, b2 = self.read_bytes(2) + except TimeoutError: + return + except SocketError as e: # to be replaced with ConnectionResetError for py3 + if e.errno == errno.ECONNRESET: + logger.info("Client closed connection.") + self.keep_alive = 0 + return + b1, b2 = 0, 0 + except ValueError as e: + b1, b2 = 0, 0 + + fin = b1 & FIN + opcode = b1 & OPCODE + masked = b2 & MASKED + payload_length = b2 & PAYLOAD_LEN + + if opcode == OPCODE_CLOSE_CONN: + logger.info("Client asked to close connection.") + self.keep_alive = 0 + return + if not masked: + logger.warning("Client must always be masked.") + self.keep_alive = 0 + return + if opcode == OPCODE_CONTINUATION: + logger.warning("Continuation frames are not supported.") + return + elif opcode == OPCODE_BINARY: + logger.warning("Binary frames are not supported.") + return + elif opcode == OPCODE_TEXT: + opcode_handler = self.server._message_received_ + elif opcode == OPCODE_PING: + opcode_handler = self.server._ping_received_ + elif opcode == OPCODE_PONG: + opcode_handler = self.server._pong_received_ + else: + logger.warning("Unknown opcode %#x." % opcode) + self.keep_alive = 0 + return + + if payload_length == 126: + payload_length = struct.unpack(">H", self.rfile.read(2))[0] + elif payload_length == 127: + payload_length = struct.unpack(">Q", self.rfile.read(8))[0] + + masks = self.read_bytes(4) + message_bytes = bytearray() + for message_byte in self.read_bytes(payload_length): + message_byte ^= masks[len(message_bytes) % 4] + message_bytes.append(message_byte) + opcode_handler(self, message_bytes.decode('utf8')) + + def send_message(self, message): + self.send_text(message) + + def send_pong(self, message): + self.send_text(message, OPCODE_PONG) + + def send_close(self, status=CLOSE_STATUS_NORMAL, reason=DEFAULT_CLOSE_REASON): + """ + Send CLOSE to client + + Args: + status: Status as defined in https://datatracker.ietf.org/doc/html/rfc6455#section-7.4.1 + reason: Text with reason of closing the connection + """ + if status < CLOSE_STATUS_NORMAL or status > 1015: + raise Exception(f"CLOSE status must be between 1000 and 1015, got {status}") + + header = bytearray() + payload = struct.pack('!H', status) + reason + payload_length = len(payload) + assert payload_length <= 125, "We only support short closing reasons at the moment" + + # Send CLOSE with status & reason + header.append(FIN | OPCODE_CLOSE_CONN) + header.append(payload_length) + with self._send_lock: + self.request.send(header + payload) + + def send_text(self, message, opcode=OPCODE_TEXT): + """ + Important: Fragmented(=continuation) messages are not supported since + their usage cases are limited - when we don't know the payload length. + """ + + # Validate message + if isinstance(message, bytes): + message = try_decode_UTF8(message) # this is slower but ensures we have UTF-8 + if not message: + logger.warning("Can\'t send message, message is not valid UTF-8") + return False + elif not isinstance(message, str): + logger.warning('Can\'t send message, message has to be a string or bytes. Got %s' % type(message)) + return False + + header = bytearray() + payload = encode_to_UTF8(message) + payload_length = len(payload) + + # Normal payload + if payload_length <= 125: + header.append(FIN | opcode) + header.append(payload_length) + + # Extended payload + elif payload_length >= 126 and payload_length <= 65535: + header.append(FIN | opcode) + header.append(PAYLOAD_LEN_EXT16) + header.extend(struct.pack(">H", payload_length)) + + # Huge extended payload + elif payload_length < 18446744073709551616: + header.append(FIN | opcode) + header.append(PAYLOAD_LEN_EXT64) + header.extend(struct.pack(">Q", payload_length)) + + else: + raise Exception("Message is too big. Consider breaking it into chunks.") + return + + with self._send_lock: + self.request.send(header + payload) + + def read_http_headers(self): + headers = {} + # first line should be HTTP GET + http_get = self.rfile.readline().decode().strip() + assert http_get.upper().startswith('GET') + # remaining should be headers + while True: + header = self.rfile.readline().decode().strip() + if not header: + break + head, value = header.split(':', 1) + headers[head.lower().strip()] = value.strip() + return headers + + def handshake(self): + headers = self.read_http_headers() + + try: + assert headers['upgrade'].lower() == 'websocket' + except AssertionError: + self.keep_alive = False + return + + try: + key = headers['sec-websocket-key'] + except KeyError: + logger.warning("Client tried to connect but was missing a key") + self.keep_alive = False + return + + response = self.make_handshake_response(key) + with self._send_lock: + self.handshake_done = self.request.send(response.encode()) + self.valid_client = True + self.server._new_client_(self) + + @classmethod + def make_handshake_response(cls, key): + return \ + 'HTTP/1.1 101 Switching Protocols\r\n'\ + 'Upgrade: websocket\r\n' \ + 'Connection: Upgrade\r\n' \ + 'Sec-WebSocket-Accept: %s\r\n' \ + '\r\n' % cls.calculate_response_key(key) + + @classmethod + def calculate_response_key(cls, key): + GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' + hash = sha1(key.encode() + GUID.encode()) + response_key = b64encode(hash.digest()).strip() + return response_key.decode('ASCII') + + def finish(self): + self.server._client_left_(self) + + +def encode_to_UTF8(data): + try: + return data.encode('UTF-8') + except UnicodeEncodeError as e: + logger.error("Could not encode data to UTF-8 -- %s" % e) + return False + except Exception as e: + raise(e) + return False + + +def try_decode_UTF8(data): + try: + return data.decode('utf-8') + except UnicodeDecodeError: + return False + except Exception as e: + raise(e) diff --git a/basicswap/http_server.py b/basicswap/http_server.py index a4bc8db..6f811ff 100644 --- a/basicswap/http_server.py +++ b/basicswap/http_server.py @@ -141,6 +141,17 @@ class HttpHandler(BaseHTTPRequestHandler): self.server.last_form_id[name] = form_id return form_data + def render_template(self, template, args_dict): + swap_client = self.server.swap_client + if swap_client.ws_server: + args_dict['ws_url'] = swap_client.ws_server.url + return bytes(template.render( + title=self.server.title, + h2=self.server.title, + form_id=os.urandom(8).hex(), + **args_dict, + ), 'UTF-8') + def page_explorers(self, url_split, post_string): swap_client = self.server.swap_client @@ -174,16 +185,13 @@ class HttpHandler(BaseHTTPRequestHandler): result = str(ex) template = env.get_template('explorers.html') - return bytes(template.render( - title=self.server.title, - h2=self.server.title, - explorers=listAvailableExplorers(swap_client), - explorer=explorer, - actions=listExplorerActions(swap_client), - action=action, - result=result, - form_id=os.urandom(8).hex(), - ), 'UTF-8') + return self.render_template(template, { + 'explorers': listAvailableExplorers(swap_client), + 'explorer': explorer, + 'actions': listExplorerActions(swap_client), + 'action': action, + 'result': result + }) def page_rpc(self, url_split, post_string): swap_client = self.server.swap_client @@ -237,15 +245,12 @@ class HttpHandler(BaseHTTPRequestHandler): coins.append((-3, 'Monero JSON')) coins.append((-4, 'Monero Wallet')) - return bytes(template.render( - title=self.server.title, - h2=self.server.title, - coins=coins, - coin_type=coin_id, - result=result, - messages=messages, - form_id=os.urandom(8).hex(), - ), 'UTF-8') + return self.render_template(template, { + 'coins': coins, + 'coin_type': coin_id, + 'result': result, + 'messages': messages, + }) def page_debug(self, url_split, post_string): swap_client = self.server.swap_client @@ -262,25 +267,20 @@ class HttpHandler(BaseHTTPRequestHandler): messages.append('Failed.') template = env.get_template('debug.html') - return bytes(template.render( - title=self.server.title, - h2=self.server.title, - messages=messages, - result=result, - form_id=os.urandom(8).hex(), - ), 'UTF-8') + return self.render_template(template, { + 'messages': messages, + 'result': result, + }) def page_active(self, url_split, post_string): swap_client = self.server.swap_client active_swaps = swap_client.listSwapsInProgress() template = env.get_template('active.html') - return bytes(template.render( - title=self.server.title, - refresh=30, - h2=self.server.title, - active_swaps=[(s[0].hex(), s[1], strBidState(s[2]), strTxState(s[3]), strTxState(s[4])) for s in active_swaps], - ), 'UTF-8') + return self.render_template(template, { + 'refresh': 30, + 'active_swaps': [(s[0].hex(), s[1], strBidState(s[2]), strTxState(s[3]), strTxState(s[4])) for s in active_swaps], + }) def page_settings(self, url_split, post_string): swap_client = self.server.swap_client @@ -347,13 +347,10 @@ class HttpHandler(BaseHTTPRequestHandler): chains_formatted[-1]['can_disable'] = True template = env.get_template('settings.html') - return bytes(template.render( - title=self.server.title, - h2=self.server.title, - messages=messages, - chains=chains_formatted, - form_id=os.urandom(8).hex(), - ), 'UTF-8') + return self.render_template(template, { + 'messages': messages, + 'chains': chains_formatted, + }) def page_bid(self, url_split, post_string): ensure(len(url_split) > 2, 'Bid ID not specified') @@ -428,16 +425,13 @@ class HttpHandler(BaseHTTPRequestHandler): data['addr_from_label'] = '(' + data['addr_from_label'] + ')' template = env.get_template('bid_xmr.html') if offer.swap_type == SwapTypes.XMR_SWAP else env.get_template('bid.html') - return bytes(template.render( - title=self.server.title, - h2=self.server.title, - bid_id=bid_id.hex(), - messages=messages, - data=data, - edit_bid=edit_bid, - form_id=os.urandom(8).hex(), - old_states=old_states, - ), 'UTF-8') + return self.render_template(template, { + 'bid_id': bid_id.hex(), + 'messages': messages, + 'data': data, + 'edit_bid': edit_bid, + 'old_states': old_states, + }) def page_bids(self, url_split, post_string, sent=False, available=False): swap_client = self.server.swap_client @@ -486,30 +480,25 @@ class HttpHandler(BaseHTTPRequestHandler): } template = env.get_template('bids.html') - return bytes(template.render( - title=self.server.title, - h2=self.server.title, - page_type='Sent' if sent else 'Received', - messages=messages, - filters=filters, - data=page_data, - bids=[(format_timestamp(b[0]), - b[2].hex(), b[3].hex(), strBidState(b[5]), strTxState(b[7]), strTxState(b[8]), b[11]) for b in bids], - form_id=os.urandom(8).hex(), - ), 'UTF-8') + return self.render_template(template, { + 'page_type': 'Sent' if sent else 'Received', + 'messages': messages, + 'filters': filters, + 'data': page_data, + 'bids': [(format_timestamp(b[0]), + b[2].hex(), b[3].hex(), strBidState(b[5]), strTxState(b[7]), strTxState(b[8]), b[11]) for b in bids], + }) def page_watched(self, url_split, post_string): swap_client = self.server.swap_client watched_outputs, last_scanned = swap_client.listWatchedOutputs() template = env.get_template('watched.html') - return bytes(template.render( - title=self.server.title, - refresh=30, - h2=self.server.title, - last_scanned=[(getCoinName(ls[0]), ls[1]) for ls in last_scanned], - watched_outputs=[(wo[1].hex(), getCoinName(wo[0]), wo[2], wo[3], int(wo[4])) for wo in watched_outputs], - ), 'UTF-8') + return self.render_template(template, { + 'refresh': 30, + 'last_scanned': [(getCoinName(ls[0]), ls[1]) for ls in last_scanned], + 'watched_outputs': [(wo[1].hex(), getCoinName(wo[0]), wo[2], wo[3], int(wo[4])) for wo in watched_outputs], + }) def page_smsgaddresses(self, url_split, post_string): swap_client = self.server.swap_client @@ -575,15 +564,12 @@ class HttpHandler(BaseHTTPRequestHandler): addr['type'] = strAddressType(addr['type']) template = env.get_template('smsgaddresses.html') - return bytes(template.render( - title=self.server.title, - h2=self.server.title, - messages=messages, - data=page_data, - form_id=os.urandom(8).hex(), - smsgaddresses=smsgaddresses, - network_addr=network_addr, - ), 'UTF-8') + return self.render_template(template, { + 'messages': messages, + 'data': page_data, + 'smsgaddresses': smsgaddresses, + 'network_addr': network_addr, + }) def page_identity(self, url_split, post_string): ensure(len(url_split) > 2, 'Address not specified') @@ -620,13 +606,10 @@ class HttpHandler(BaseHTTPRequestHandler): messages.append(e) template = env.get_template('identity.html') - return bytes(template.render( - title=self.server.title, - h2=self.server.title, - messages=messages, - data=page_data, - form_id=os.urandom(8).hex(), - ), 'UTF-8') + return self.render_template(template, { + 'messages': messages, + 'data': page_data, + }) def page_shutdown(self, url_split, post_string): swap_client = self.server.swap_client @@ -649,15 +632,13 @@ class HttpHandler(BaseHTTPRequestHandler): self.server.session_tokens['shutdown'] = shutdown_token template = env.get_template('index.html') - return bytes(template.render( - title=self.server.title, - refresh=30, - h2=self.server.title, - version=__version__, - summary=summary, - use_tor_proxy=swap_client.use_tor_proxy, - shutdown_token=shutdown_token - ), 'UTF-8') + return self.render_template(template, { + 'refresh': 30, + 'version': __version__, + 'summary': summary, + 'use_tor_proxy': swap_client.use_tor_proxy, + 'shutdown_token': shutdown_token + }) def page_404(self, url_split): template = env.get_template('404.html') @@ -708,10 +689,11 @@ class HttpHandler(BaseHTTPRequestHandler): elif len(url_split) > 3 and url_split[2] == 'images': filename = os.path.join(*url_split[3:]) _, extension = os.path.splitext(filename) - mime_type = {'.svg': 'image/svg+xml', - '.png': 'image/png', - '.jpg': 'image/jpeg', - }.get(extension, '') + mime_type = { + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + }.get(extension, '') if mime_type == '': raise ValueError('Unknown file type ' + filename) with open(os.path.join(static_path, 'images', filename), 'rb') as fp: @@ -737,51 +719,52 @@ class HttpHandler(BaseHTTPRequestHandler): try: self.putHeaders(status_code, 'text/html') if len(url_split) > 1: - if url_split[1] == 'active': + page = url_split[1] + if page == 'active': return self.page_active(url_split, post_string) - if url_split[1] == 'wallets': + if page == 'wallets': return page_wallets(self, url_split, post_string) - if url_split[1] == 'wallet': + if page == 'wallet': return page_wallet(self, url_split, post_string) - if url_split[1] == 'settings': + if page == 'settings': return self.page_settings(url_split, post_string) - if url_split[1] == 'rpc': + if page == 'rpc': return self.page_rpc(url_split, post_string) - if url_split[1] == 'debug': + if page == 'debug': return self.page_debug(url_split, post_string) - if url_split[1] == 'explorers': + if page == 'explorers': return self.page_explorers(url_split, post_string) - if url_split[1] == 'offer': + if page == 'offer': return page_offer(self, url_split, post_string) - if url_split[1] == 'offers': + if page == 'offers': return page_offers(self, url_split, post_string) - if url_split[1] == 'newoffer': + if page == 'newoffer': return page_newoffer(self, url_split, post_string) - if url_split[1] == 'sentoffers': + if page == 'sentoffers': return page_offers(self, url_split, post_string, sent=True) - if url_split[1] == 'bid': + if page == 'bid': return self.page_bid(url_split, post_string) - if url_split[1] == 'bids': + if page == 'bids': return self.page_bids(url_split, post_string) - if url_split[1] == 'sentbids': + if page == 'sentbids': return self.page_bids(url_split, post_string, sent=True) - if url_split[1] == 'availablebids': + if page == 'availablebids': return self.page_bids(url_split, post_string, available=True) - if url_split[1] == 'watched': + if page == 'watched': return self.page_watched(url_split, post_string) - if url_split[1] == 'smsgaddresses': + if page == 'smsgaddresses': return self.page_smsgaddresses(url_split, post_string) - if url_split[1] == 'identity': + if page == 'identity': return self.page_identity(url_split, post_string) - if url_split[1] == 'tor': + if page == 'tor': return page_tor(self, url_split, post_string) - if url_split[1] == 'automation': + if page == 'automation': return page_automation_strategies(self, url_split, post_string) - if url_split[1] == 'automationstrategy': + if page == 'automationstrategy': return page_automation_strategy(self, url_split, post_string) - if url_split[1] == 'newautomationstrategy': + if page == 'newautomationstrategy': return page_automation_strategy_new(self, url_split, post_string) - if url_split[1] == 'shutdown': + if page == 'shutdown': return self.page_shutdown(url_split, post_string) return self.page_index(url_split) except Exception as ex: diff --git a/basicswap/static/css/simple/style.css b/basicswap/static/css/simple/style.css index a7bc895..dbf3da9 100644 --- a/basicswap/static/css/simple/style.css +++ b/basicswap/static/css/simple/style.css @@ -13,3 +13,12 @@ { font-family:monospace; } + +.floatright +{ + position:fixed; + top:10px; + right:18px; + margin: 0; + width:calc(33.33% - 25px); +} diff --git a/basicswap/templates/header.html b/basicswap/templates/header.html index 4311018..f761188 100644 --- a/basicswap/templates/header.html +++ b/basicswap/templates/header.html @@ -12,3 +12,33 @@ {% if h2 %}

{{ h2 }}

{% endif %} + +{% if ws_url %} + +{% endif %} diff --git a/basicswap/ui/page_automation.py b/basicswap/ui/page_automation.py index 9e22cea..701e889 100644 --- a/basicswap/ui/page_automation.py +++ b/basicswap/ui/page_automation.py @@ -4,8 +4,6 @@ # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. -import os - from .util import ( PAGE_LIMIT, get_data_entry, @@ -51,14 +49,11 @@ def page_automation_strategies(self, url_split, post_string): formatted_strategies.append((s[0], s[1], strConcepts(s[2]))) template = server.env.get_template('automation_strategies.html') - return bytes(template.render( - title=server.title, - h2=server.title, - messages=messages, - filters=filters, - strategies=formatted_strategies, - form_id=os.urandom(8).hex(), - ), 'UTF-8') + return self.render_template(template, { + 'messages': messages, + 'filters': filters, + 'strategies': formatted_strategies, + }) def page_automation_strategy_new(self, url_split, post_string): @@ -69,12 +64,9 @@ def page_automation_strategy_new(self, url_split, post_string): form_data = self.checkForm(post_string, 'automationstrategynew', messages) template = server.env.get_template('automation_strategy_new.html') - return bytes(template.render( - title=server.title, - h2=server.title, - messages=messages, - form_id=os.urandom(8).hex(), - ), 'UTF-8') + return self.render_template(template, { + 'messages': messages, + }) def page_automation_strategy(self, url_split, post_string): @@ -101,10 +93,7 @@ def page_automation_strategy(self, url_split, post_string): } template = server.env.get_template('automation_strategy.html') - return bytes(template.render( - title=server.title, - h2=server.title, - messages=messages, - strategy=formatted_strategy, - form_id=os.urandom(8).hex(), - ), 'UTF-8') + return self.render_template(template, { + 'messages': messages, + 'strategy': formatted_strategy, + }) diff --git a/basicswap/ui/page_offers.py b/basicswap/ui/page_offers.py index dc67910..5269844 100644 --- a/basicswap/ui/page_offers.py +++ b/basicswap/ui/page_offers.py @@ -4,7 +4,6 @@ # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. -import os import traceback from .util import ( @@ -308,18 +307,15 @@ def page_newoffer(self, url_split, post_string): automation_filters['type_ind'] = Concepts.OFFER automation_strategies = swap_client.listAutomationStrategies(automation_filters) - return bytes(template.render( - title=server.title, - h2=server.title, - messages=messages, - coins_from=coins_from, - coins=coins_to, - addrs=swap_client.listSmsgAddresses('offer_send_from'), - addrs_to=swap_client.listSmsgAddresses('offer_send_to'), - data=page_data, - automation_strategies=automation_strategies, - form_id=os.urandom(8).hex(), - ), 'UTF-8') + return self.render_template(template, { + 'messages': messages, + 'coins_from': coins_from, + 'coins': coins_to, + 'addrs': swap_client.listSmsgAddresses('offer_send_from'), + 'addrs_to': swap_client.listSmsgAddresses('offer_send_to'), + 'data': page_data, + 'automation_strategies': automation_strategies, + }) def page_offer(self, url_split, post_string): @@ -469,17 +465,14 @@ def page_offer(self, url_split, post_string): data['amt_swapped'] = ci_from.format_amount(amt_swapped) template = server.env.get_template('offer.html') - return bytes(template.render( - title=server.title, - h2=server.title, - offer_id=offer_id.hex(), - sent_bid_id=sent_bid_id, - messages=messages, - data=data, - bids=formatted_bids, - addrs=None if show_bid_form is None else swap_client.listSmsgAddresses('bid'), - form_id=os.urandom(8).hex(), - ), 'UTF-8') + return self.render_template(template, { + 'offer_id': offer_id.hex(), + 'sent_bid_id': sent_bid_id, + 'messages': messages, + 'data': data, + 'bids': formatted_bids, + 'addrs': None if show_bid_form is None else swap_client.listSmsgAddresses('bid'), + }) def page_offers(self, url_split, post_string, sent=False): @@ -540,12 +533,10 @@ def page_offers(self, url_split, post_string, sent=False): ci_from.format_amount(completed_amount))) template = server.env.get_template('offers.html') - return bytes(template.render( - title=server.title, - h2=server.title, - coins=listAvailableCoins(swap_client), - messages=messages, - filters=filters, - offers=formatted_offers, - form_id=os.urandom(8).hex(), - ), 'UTF-8') + return self.render_template(template, { + 'messages': messages, + 'coins': listAvailableCoins(swap_client), + 'messages': messages, + 'filters': filters, + 'offers': formatted_offers, + }) diff --git a/basicswap/ui/page_tor.py b/basicswap/ui/page_tor.py index b9d93e1..4da5596 100644 --- a/basicswap/ui/page_tor.py +++ b/basicswap/ui/page_tor.py @@ -4,9 +4,6 @@ # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. -import os - - def extract_data(bytes_in): str_in = bytes_in.decode('utf-8') start = str_in.find('=') @@ -37,10 +34,7 @@ def page_tor(self, url_split, post_string): messages = [] template = self.server.env.get_template('tor.html') - return bytes(template.render( - title=self.server.title, - h2=self.server.title, - messages=messages, - data=page_data, - form_id=os.urandom(8).hex(), - ), 'UTF-8') + return self.render_template(template, { + 'messages': messages, + 'data': page_data, + }) diff --git a/basicswap/ui/page_wallet.py b/basicswap/ui/page_wallet.py index 1481674..432d52d 100644 --- a/basicswap/ui/page_wallet.py +++ b/basicswap/ui/page_wallet.py @@ -4,7 +4,6 @@ # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. -import os import traceback from .util import ( @@ -151,13 +150,10 @@ def page_wallets(self, url_split, post_string): wallets_formatted.append(wf) template = server.env.get_template('wallets.html') - return bytes(template.render( - title=server.title, - h2=server.title, - messages=messages, - wallets=wallets_formatted, - form_id=os.urandom(8).hex(), - ), 'UTF-8') + return self.render_template(template, { + 'messages': messages, + 'wallets': wallets_formatted, + }) def page_wallet(self, url_split, post_string): @@ -304,10 +300,7 @@ def page_wallet(self, url_split, post_string): wallet_data['utxo_groups'] = utxo_groups template = server.env.get_template('wallet.html') - return bytes(template.render( - title=server.title, - h2=server.title, - messages=messages, - w=wallet_data, - form_id=os.urandom(8).hex(), - ), 'UTF-8') + return self.render_template(template, { + 'messages': messages, + 'w': wallet_data, + }) diff --git a/bin/basicswap_prepare.py b/bin/basicswap_prepare.py index e7ee655..8c0804e 100755 --- a/bin/basicswap_prepare.py +++ b/bin/basicswap_prepare.py @@ -80,6 +80,7 @@ if not len(logger.handlers): logger.addHandler(logging.StreamHandler(sys.stdout)) UI_HTML_PORT = int(os.getenv('UI_HTML_PORT', 12700)) +UI_WS_PORT = int(os.getenv('UI_WS_PORT', 11700)) COINS_RPCBIND_IP = os.getenv('COINS_RPCBIND_IP', '127.0.0.1') PART_ZMQ_PORT = int(os.getenv('PART_ZMQ_PORT', 20792)) @@ -748,7 +749,8 @@ def printHelp(): logger.info('--nocores Don\'t download and extract any coin clients.') logger.info('--usecontainers Expect each core to run in a unique container.') logger.info('--portoffset=n Raise all ports by n.') - logger.info('--htmlhost= Interface to host on, default:127.0.0.1.') + logger.info('--htmlhost= Interface to host html server on, default:127.0.0.1.') + logger.info('--wshost= Interface to host websocket server on, disable by setting to "none", default:127.0.0.1.') logger.info('--xmrrestoreheight=n Block height to restore Monero wallet from, default:{}.'.format(DEFAULT_XMR_RESTORE_HEIGHT)) logger.info('--noextractover Prevent extracting cores if files exist. Speeds up tests') logger.info('--usetorproxy Use TOR proxy during setup. Note that some download links may be inaccessible over TOR.') @@ -853,6 +855,7 @@ def main(): add_coin = '' disable_coin = '' htmlhost = '127.0.0.1' + wshost = '127.0.0.1' xmr_restore_height = DEFAULT_XMR_RESTORE_HEIGHT prepare_bin_only = False no_cores = False @@ -955,6 +958,9 @@ def main(): if name == 'htmlhost': htmlhost = s[1].strip('"') continue + if name == 'wshost': + wshost = s[1].strip('"') + continue if name == 'xmrrestoreheight': xmr_restore_height = int(s[1]) continue @@ -1200,6 +1206,10 @@ def main(): 'check_expired_seconds': 60 } + if wshost != 'none': + settings['wshost'] = wshost + settings['wsport'] = UI_WS_PORT + port_offset + if use_tor_proxy: tor_control_password = generate_salt(24) addTorSettings(settings, tor_control_password) diff --git a/bin/basicswap_run.py b/bin/basicswap_run.py index 6b4b7b3..e7a62c2 100755 --- a/bin/basicswap_run.py +++ b/bin/basicswap_run.py @@ -19,6 +19,7 @@ import basicswap.config as cfg from basicswap import __version__ from basicswap.basicswap import BasicSwap from basicswap.http_server import HttpThread +from basicswap.contrib.websocket_server import WebsocketServer logger = logging.getLogger() @@ -93,6 +94,25 @@ def startXmrWalletDaemon(node_dir, bin_dir, wallet_bin, opts=[]): return subprocess.Popen(args, stdin=subprocess.PIPE, stdout=wallet_stdout, stderr=wallet_stderr, cwd=data_dir) +def ws_new_client(client, server): + if swap_client: + swap_client.log.debug(f'ws_new_client {client["id"]}') + + +def ws_client_left(client, server): + if client is None: + return + if swap_client: + swap_client.log.debug(f'ws_client_left {client["id"]}') + + +def ws_message_received(client, server, message): + if len(message) > 200: + message = message[:200] + '..' + if swap_client: + swap_client.log.debug(f'ws_message_received {client["id"]} {message}') + + def runClient(fp, data_dir, chain): global swap_client settings_path = os.path.join(data_dir, cfg.CONFIG_FILENAME) @@ -158,24 +178,45 @@ def runClient(fp, data_dir, chain): swap_client.start() if 'htmlhost' in settings: - swap_client.log.info('Starting server at http://%s:%d.' % (settings['htmlhost'], settings['htmlport'])) + swap_client.log.info('Starting http server at http://%s:%d.' % (settings['htmlhost'], settings['htmlport'])) allow_cors = settings['allowcors'] if 'allowcors' in settings else cfg.DEFAULT_ALLOW_CORS - tS1 = HttpThread(fp, settings['htmlhost'], settings['htmlport'], allow_cors, swap_client) - threads.append(tS1) - tS1.start() + thread_http = HttpThread(fp, settings['htmlhost'], settings['htmlport'], allow_cors, swap_client) + threads.append(thread_http) + thread_http.start() + + if 'wshost' in settings: + ws_url = 'ws://{}:{}'.format(settings['wshost'], settings['wsport']) + swap_client.log.info(f'Starting ws server at {ws_url}.') + + swap_client.ws_server = WebsocketServer(host=settings['wshost'], port=settings['wsport']) + swap_client.ws_server.set_fn_new_client(ws_new_client) + swap_client.ws_server.set_fn_client_left(ws_client_left) + swap_client.ws_server.set_fn_message_received(ws_message_received) + swap_client.ws_server.run_forever(threaded=True) logger.info('Exit with Ctrl + c.') while swap_client.is_running: time.sleep(0.5) swap_client.update() + except Exception as ex: traceback.print_exc() + if swap_client.ws_server: + try: + swap_client.log.info('Stopping websocket server.') + swap_client.ws_server.shutdown_gracefully() + except Exception as ex: + traceback.print_exc() + swap_client.finalise() swap_client.log.info('Stopping HTTP threads.') for t in threads: - t.stop() - t.join() + try: + t.stop() + t.join() + except Exception as ex: + traceback.print_exc() closed_pids = [] for d in daemons: diff --git a/doc/upgrade.md b/doc/upgrade.md index df5c289..221ee36 100644 --- a/doc/upgrade.md +++ b/doc/upgrade.md @@ -7,16 +7,16 @@ Update only the code: basicswap]$ git pull $ cd docker - $ docker-compose build $ export COINDATA_PATH=[PATH_TO] + $ docker-compose build $ docker-compose up If the dependencies have changed the container must be built with `--no-cache`: basicswap]$ git pull $ cd docker - $ docker-compose build --no-cache $ export COINDATA_PATH=[PATH_TO] + $ docker-compose build --no-cache $ docker-compose up diff --git a/docker/.env b/docker/.env index da8ef90..8450428 100644 --- a/docker/.env +++ b/docker/.env @@ -1,3 +1,4 @@ HTML_PORT=127.0.0.1:12700:12700 +WS_PORT=127.0.0.1:11700:11700 #COINDATA_PATH=/var/data/coinswaps TZ=UTC diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index b6ce17b..17c23b0 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -9,6 +9,7 @@ services: - ${COINDATA_PATH}:/coindata ports: - "${HTML_PORT}" # Expose only to localhost, see .env + - "${WS_PORT}" # Expose only to localhost, see .env environment: - TZ logging: diff --git a/docker/docker-compose_with_tor.yml b/docker/docker-compose_with_tor.yml index e9f454e..eba598c 100644 --- a/docker/docker-compose_with_tor.yml +++ b/docker/docker-compose_with_tor.yml @@ -11,6 +11,7 @@ services: - ${COINDATA_PATH}:/coindata ports: - "${HTML_PORT}" # Expose only to localhost, see .env + - "${WS_PORT}" # Expose only to localhost, see .env environment: - TZ - TOR_PROXY_HOST diff --git a/docker/production/compose-fragments/8_swapclient.yml b/docker/production/compose-fragments/8_swapclient.yml index f80165e..fbabc56 100644 --- a/docker/production/compose-fragments/8_swapclient.yml +++ b/docker/production/compose-fragments/8_swapclient.yml @@ -8,6 +8,7 @@ - ${DATA_PATH}/swapclient:/data ports: - "${HTML_PORT}" # Expose only to localhost, see .env + - "${WS_PORT}" # Expose only to localhost, see .env environment: - TZ logging: diff --git a/docker/production/example.env b/docker/production/example.env index 3608142..024d1f0 100644 --- a/docker/production/example.env +++ b/docker/production/example.env @@ -1,4 +1,5 @@ HTML_PORT=127.0.0.1:12700:12700 +WS_PORT=127.0.0.1:11700:11700 TZ=UTC DATA_PATH=/var/swapdata/