electrum-obelisk

Electrum server using libbitcoin as its backend
git clone https://git.parazyd.org/electrum-obelisk
Log | Files | Refs | README | LICENSE

obelisk (8955B)


      1 #!/usr/bin/env python3
      2 # electrum-obelisk
      3 # Copyright (C) 2020-2021 Ivan J. <parazyd@dyne.org>
      4 # Copyright (C) 2018-2020 Chris Belcher (MIT License)
      5 #
      6 # This program is free software: you can redistribute it and/or modify
      7 # it under the terms of the GNU Affero General Public License as published by
      8 # the Free Software Foundation, either version 3 of the License, or
      9 # (at your option) any later version.
     10 #
     11 # This program is distributed in the hope that it will be useful,
     12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
     13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     14 # GNU Affero General Public License for more details.
     15 #
     16 # You should have received a copy of the GNU Affero General Public License
     17 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
     18 """
     19 Main module for electrum-obelisk server
     20 """
     21 import ssl
     22 import sys
     23 import socket
     24 from os.path import join, exists
     25 from json import dumps, loads
     26 from json.decoder import JSONDecodeError
     27 from ipaddress import ip_network, ip_address
     28 from tempfile import gettempdir
     29 from configparser import RawConfigParser, NoSectionError
     30 from logging import getLogger, FileHandler, Formatter, StreamHandler, DEBUG
     31 from argparse import ArgumentParser
     32 
     33 from pkg_resources import resource_filename
     34 
     35 from electrumobelisk.bx import bx_raw
     36 from electrumobelisk.protocol import (VERSION, ElectrumProtocol, DONATION_ADDR)
     37 
     38 
     39 def logger_config(log, config):
     40     """ Setup logging """
     41     fmt = Formatter(config.get('logging', 'log_format',
     42                     fallback='%(asctime)s\t%(levelname)s\t%(message)s'))
     43     logstream = StreamHandler()
     44     logstream.setFormatter(fmt)
     45     logstream.setLevel(config.get('logging', 'log_level_stdout',
     46                        fallback='DEBUG'))
     47     log.addHandler(logstream)
     48     filename = config.get('logging', 'log_file_location', fallback='')
     49     if len(filename.strip()) == 0:
     50         filename = join(gettempdir(), 'obelisk.log')
     51     logfile = FileHandler(filename, mode=('a' if config.get(
     52                           'logging', 'append_log', fallback='false') else 'w'))
     53     logfile.setFormatter(fmt)
     54     logfile.setLevel(DEBUG)
     55     log.addHandler(logfile)
     56     log.setLevel(DEBUG)
     57     return log, filename
     58 
     59 
     60 
     61 def get_certs(config):
     62     """ Get filepaths to TLS certificate and key """
     63     certfile = config.get('electrum-obelisk', 'certfile', fallback=None)
     64     keyfile = config.get('electrum-obelisk', 'keyfile', fallback=None)
     65     if (certfile and keyfile) and (exists(certfile) and exists(keyfile)):
     66         return certfile, keyfile
     67 
     68     certfile = resource_filename('electrumobelisk', 'certs/cert.crt')
     69     keyfile = resource_filename('electrumobelisk', 'certs/cert.key')
     70     if exists(certfile) and exists(keyfile):
     71         return certfile, keyfile
     72 
     73     raise ValueError('invalid cert: %s, key: %s' % (certfile, keyfile))
     74 
     75 
     76 def create_server_socket(hostport):
     77     """ Set up listen socket """
     78     log = getLogger('electrum-obelisk')
     79     server_sock = socket.socket()
     80     server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
     81     server_sock.bind(hostport)
     82     server_sock.listen(1)
     83     log.info('Listening for clients on %s', str(hostport))
     84     log.info('Consider donating to electrum-obelisk: %s', DONATION_ADDR)
     85     return server_sock
     86 
     87 
     88 def fill_ip_whitelist(config):
     89     """ Parses configuration option(s) for IP address whitelist """
     90     ip_w = []
     91     for i in config.get('electrum-obelisk', 'ip_whitelist').split(' '):
     92         if i == '*':
     93             # Matches everything
     94             ip_w.append(ip_network('0.0.0.0/0'))
     95             ip_w.append(ip_network('::0/0'))
     96         else:
     97             ip_w.append(ip_network(i, strict=False))
     98     return ip_w
     99 
    100 
    101 def run_electrum_server(config, chain):
    102     """ Basic TLS socket server """
    103     log = getLogger('electrum-obelisk')
    104     hostport = (config.get('electrum-obelisk', 'host'),
    105             int(config.get('electrum-obelisk', 'port')))
    106     ip_whitelist = fill_ip_whitelist(config)
    107 
    108     if config.getboolean('electrum-obelisk', 'usetls', fallback=True):
    109         certfile, keyfile = get_certs(config)
    110         log.debug('Using cert: %s, key: %s', certfile, keyfile)
    111 
    112     broadcast_method = config.get('electrum-obelisk', 'broadcast_method',
    113                                   fallback='own-node')
    114     tor_host = config.get('electrum-obelisk', 'tor_host', fallback='localhost')
    115     tor_port = int(config.get('electrum-obelisk', 'tor_port', fallback=9050))
    116     tor_hostport = (tor_host, tor_port)
    117 
    118     endpoints = {}
    119     endpoints['query'] = config.get('electrum-obelisk', 'bs_query')
    120     endpoints['heartbeat'] = config.get('electrum-obelisk', 'bs_heartbeat')
    121     endpoints['block'] = config.get('electrum-obelisk', 'bs_block')
    122     endpoints['transaction'] = config.get('electrum-obelisk', 'bs_transaction')
    123 
    124     protocol = ElectrumProtocol(log, chain, endpoints)
    125 
    126     server_sock = create_server_socket(hostport)
    127     # server_sock.settimeout(30)
    128 
    129     while True:
    130         sock = None
    131         while sock is None:
    132             try:
    133                 sock, addr = server_sock.accept()
    134                 if not any([ip_address(addr[0]) in ipnet
    135                                                 for ipnet in ip_whitelist]):
    136                     log.debug('%s not in whitelist. Closing...', str(addr[0]))
    137                     raise ConnectionRefusedError()
    138                 if config.getboolean('electrum-obelisk', 'usetls',
    139                                      fallback=True):
    140                     sock = ssl.wrap_socket(sock, server_side=True,
    141                                            certfile=certfile, keyfile=keyfile,
    142                                            ssl_version=ssl.PROTOCOL_TLS)
    143                 log.debug('sock.accept')
    144             except (ConnectionRefusedError, ssl.SSLError, IOError):
    145                 sock.close()
    146                 sock = None
    147         log.debug('Client connected from %s', str(addr[0]))
    148 
    149         def send_reply_fun(reply):
    150             line = dumps(reply)
    151             sock.sendall(line.encode('utf-8') + b'\n')
    152             log.debug('<= %s', line)
    153         protocol.set_send_reply_fun(send_reply_fun)
    154 
    155         try:
    156             #sock.settimeout(5)
    157             recv_buffer = bytearray()
    158             while True:
    159                 # Loop for replying to client queries
    160                 try:
    161                     recv_data = sock.recv(4096)
    162                     if not recv_data or len(recv_data) == 0:
    163                         raise EOFError()
    164                     recv_buffer.extend(recv_data)
    165                     _lb = recv_buffer.find(b'\n')
    166                     if _lb == -1:
    167                         continue
    168                     while _lb != -1:
    169                         line = recv_buffer[:_lb].rstrip()
    170                         recv_buffer = recv_buffer[_lb + 1:]
    171                         _lb = recv_buffer.find(b'\n')
    172                         try:
    173                             line = line.decode('utf-8')
    174                             query = loads(line)
    175                         except (UnicodeDecodeError, JSONDecodeError) as err:
    176                             raise IOError from repr(err)
    177                         log.debug('=> %s', line)
    178                         protocol.handle_query(query)
    179                 except socket.timeout:
    180                     log.debug('Socket timeout reached')
    181         except(IOError, EOFError) as err:
    182             if isinstance(err, (EOFError, ConnectionRefusedError)):
    183                 log.debug('Client disconnected')
    184             else:
    185                 log.debug('IOError: %s', repr(err))
    186     try:
    187         if sock is not None:
    188             sock.close()
    189     except IOError:
    190         pass
    191     protocol.on_disconnect()
    192 
    193 def main():
    194     """ Main orchestration """
    195     parser = ArgumentParser(description='electrum-obelisk %s' % VERSION)
    196     parser.add_argument('config_file', help='Path to config file')
    197     parser.add_argument('-v', '--version', action='version',
    198                         version='electrum-obelisk %s' % VERSION)
    199     args = parser.parse_args()
    200 
    201     try:
    202         config = RawConfigParser()
    203         config.read(args.config_file)
    204         config.options('electrum-obelisk')
    205     except NoSectionError:
    206         print(f'Error: Nonexistent config file {args.config_file}')
    207         return 1
    208 
    209     log = getLogger('electrum-obelisk')
    210     log, logfilename = logger_config(log, config)
    211     log.info('Starting electrum-obelisk %s' % VERSION)
    212     log.info('Logging to %s' % logfilename)
    213 
    214     chain = config.get('electrum-obelisk', 'chain')
    215     if chain not in ['mainnet', 'regtest', 'testnet']:
    216         log.error('"chain" is not one of "mainnet", "regtest", or "testnet"')
    217         return 1
    218 
    219     if not bx_raw(['fetch-height']):
    220         log.error("Couldn't use bx fetch-height. Please check your bx config")
    221         return 1
    222 
    223     try:
    224         run_electrum_server(config, chain)
    225     except KeyboardInterrupt:
    226         log.info('Got KeyboardInterrupt, exiting...')
    227         return 1
    228     return 0
    229 
    230 
    231 if __name__ == '__main__':
    232     sys.exit(main())