ui: Add websocket notifications.

This commit is contained in:
tecnovert 2022-07-31 19:33:01 +02:00
parent 6cc54d9c61
commit 1601a57aed
No known key found for this signature in database
GPG key ID: 8ED6D8750C4E3F93
21 changed files with 830 additions and 215 deletions

View file

@ -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

View file

@ -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)

View file

@ -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'

View file

@ -0,0 +1 @@
from .websocket_server import *

View file

@ -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 <thread>.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

View file

@ -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)

View file

@ -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:

View file

@ -13,3 +13,12 @@
{
font-family:monospace;
}
.floatright
{
position:fixed;
top:10px;
right:18px;
margin: 0;
width:calc(33.33% - 25px);
}

View file

@ -12,3 +12,33 @@
{% if h2 %}
<h2>{{ h2 }}</h2>
{% endif %}
{% if ws_url %}
<script>
var ws = new WebSocket("{{ ws_url }}"),
floating_div = document.createElement('div');
floating_div.classList.add('floatright');
messages = document.createElement('ul');
ws.onmessage = function (event) {
let json = JSON.parse(event.data);
let event_message = 'Unknown event';
if (json['event'] == 'new_offer') {
event_message = '<a href=/offer/' + json['offer_id'] + '>New offer</a>';
} else
if (json['event'] == 'new_bid') {
event_message = '<a href=/bid/' + json['bid_id'] + '>New bid</a> on offer <a href=/offer/' + json['offer_id'] + '>' + json['offer_id'] + '</a>';
} else
if (json['event'] == 'bid_accepted') {
event_message = '<a href=/bid/' + json['bid_id'] + '>Bid accepted</a>';
}
let messages = document.getElementsByTagName('ul')[0],
message = document.createElement('li');
message.innerHTML = event_message;
messages.appendChild(message);
};
floating_div.appendChild(messages);
document.body.appendChild(floating_div);
</script>
{% endif %}

View file

@ -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,
})

View file

@ -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,
})

View file

@ -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,
})

View file

@ -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,
})

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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:

View file

@ -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/