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