electrumx/server/session.py
Neil Booth 151da40d5b Implement peer discovery protocol
Closes #104

DEFAULT_PORTS now a coin property
A Peer object maintains peer information
Revamp LocalRPC "peers" call to show a lot more information
Have lib/jsonrpc.py take care of handling request timeouts
Save and restore peers to a file
Loosen JSON RPC rules so we work with electrum-server and beancurd which don't follow the spec.
Handle incoming server.add_peer requests
Send server.add_peer registrations if peer doesn't have us or correct ports
Verify peers at regular intervals, forget stale peers, verify new peers or those with updated ports
If connecting via one port fails, try the other
Add socks.py for SOCKS4 and SOCKS5 proxying, so Tor servers can now be reached by TCP and SSL
Put full licence boilerplate in lib/ files
Disable IRC advertising on testnet
Serve a Tor banner file if it seems like a connection came from your tor proxy (see ENVIONMENT.rst)
Retry tor proxy hourly, and peers that are about to turn stale
Report more onion peers to a connection that seems to be combing from your tor proxy
Only report good peers to server.peers.subscribe; always report self if valid
Handle peers on the wrong network robustly
Default to 127.0.0.1 rather than localhost for Python <= 3.5.2 compatibility
Put peer name in logs of connections to it
Update docs
2017-02-18 12:43:45 +09:00

316 lines
11 KiB
Python

# Copyright (c) 2016-2017, Neil Booth
#
# All rights reserved.
#
# See the file "LICENCE" for information about the copyright
# and warranty status of this software.
'''Classes for local RPC server and remote client TCP/SSL servers.'''
import codecs
import time
from functools import partial
from lib.jsonrpc import JSONSession, RPCError, JSONRPCv2
from server.daemon import DaemonError
import server.version as version
class SessionBase(JSONSession):
'''Base class of ElectrumX JSON sessions.
Each session runs its tasks in asynchronous parallelism with other
sessions.
'''
def __init__(self, controller, kind):
# Force v2 as a temporary hack for old Coinomi wallets
# Remove in April 2017
super().__init__(version=JSONRPCv2)
self.kind = kind # 'RPC', 'TCP' etc.
self.controller = controller
self.bp = controller.bp
self.env = controller.env
self.daemon = self.bp.daemon
self.client = 'unknown'
self.protocol_version = '1.0'
self.anon_logs = self.env.anon_logs
self.last_delay = 0
self.txs_sent = 0
self.requests = []
self.start_time = time.time()
self.close_time = 0
self.bw_limit = self.env.bandwidth_limit
self.bw_time = self.start_time
self.bw_interval = 3600
self.bw_used = 0
self.peer_added = False
def have_pending_items(self):
'''Called each time the pending item queue goes from empty to having
one item.'''
self.controller.enqueue_session(self)
def close_connection(self):
'''Call this to close the connection.'''
self.close_time = time.time()
super().close_connection()
def peername(self, *, for_log=True):
'''Return the peer address and port.'''
return self.peer_addr(anon=for_log and self.anon_logs)
def flags(self):
'''Status flags.'''
status = self.kind[0]
if self.is_closing():
status += 'C'
if self.log_me:
status += 'L'
status += str(self.controller.session_priority(self))
return status
def connection_made(self, transport):
'''Handle an incoming client connection.'''
super().connection_made(transport)
self.controller.add_session(self)
def connection_lost(self, exc):
'''Handle client disconnection.'''
super().connection_lost(exc)
msg = ''
if self.pause:
msg += ' whilst paused'
if self.controller.is_deprioritized(self):
msg += ' whilst deprioritized'
if self.send_size >= 1024*1024:
msg += ('. Sent {:,d} bytes in {:,d} messages'
.format(self.send_size, self.send_count))
if msg:
msg = 'disconnected' + msg
self.log_info(msg)
self.controller.remove_session(self)
def using_bandwidth(self, amount):
now = time.time()
# Reduce the recorded usage in proportion to the elapsed time
elapsed = now - self.bw_time
self.bandwidth_start = now
refund = int(elapsed / self.bw_interval * self.bw_limit)
refund = min(refund, self.bw_used)
self.bw_used += amount - refund
def sub_count(self):
return 0
class ElectrumX(SessionBase):
'''A TCP server that handles incoming Electrum connections.'''
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.subscribe_headers = False
self.subscribe_height = False
self.notified_height = None
self.max_send = self.env.max_send
self.max_subs = self.env.max_session_subs
self.hashX_subs = {}
self.electrumx_handlers = {
'blockchain.address.subscribe': self.address_subscribe,
'blockchain.headers.subscribe': self.headers_subscribe,
'blockchain.numblocks.subscribe': self.numblocks_subscribe,
'blockchain.transaction.broadcast': self.transaction_broadcast,
'server.add_peer': self.add_peer,
'server.banner': self.banner,
'server.features': self.server_features,
'server.peers.subscribe': self.peers_subscribe,
'server.version': self.server_version,
}
def sub_count(self):
return len(self.hashX_subs)
async def notify(self, height, touched):
'''Notify the client about changes in height and touched addresses.
Cache is a shared cache for this update.
'''
controller = self.controller
pairs = []
if height != self.notified_height:
self.notified_height = height
if self.subscribe_headers:
args = (controller.electrum_header(height), )
pairs.append(('blockchain.headers.subscribe', args))
if self.subscribe_height:
pairs.append(('blockchain.numblocks.subscribe', (height, )))
matches = touched.intersection(self.hashX_subs)
for hashX in matches:
address = self.hashX_subs[hashX]
status = await controller.address_status(hashX)
pairs.append(('blockchain.address.subscribe', (address, status)))
self.send_notifications(pairs)
if matches:
es = '' if len(matches) == 1 else 'es'
self.log_info('notified of {:,d} address{}'
.format(len(matches), es))
def height(self):
'''Return the current flushed database height.'''
return self.bp.db_height
def current_electrum_header(self):
'''Used as response to a headers subscription request.'''
return self.controller.electrum_header(self.height())
def headers_subscribe(self):
'''Subscribe to get headers of new blocks.'''
self.subscribe_headers = True
return self.current_electrum_header()
def numblocks_subscribe(self):
'''Subscribe to get height of new blocks.'''
self.subscribe_height = True
return self.height()
def add_peer(self, features):
'''Add a peer.'''
if self.peer_added:
return False
peer_mgr = self.controller.peer_mgr
peer_info = self.peer_info()
source = peer_info[0] if peer_info else 'unknown'
self.peer_added = peer_mgr.on_add_peer(features, source)
return self.peer_added
def peers_subscribe(self):
'''Return the server peers as a list of (ip, host, details) tuples.'''
return self.controller.peer_mgr.on_peers_subscribe(self.is_tor())
async def address_subscribe(self, address):
'''Subscribe to an address.
address: the address to subscribe to'''
# First check our limit.
if len(self.hashX_subs) >= self.max_subs:
raise RPCError('your address subscription limit {:,d} reached'
.format(self.max_subs))
# Now let the controller check its limit
hashX, status = await self.controller.new_subscription(address)
self.hashX_subs[hashX] = address
return status
def server_features(self):
'''Returns a dictionary of server features.'''
return self.controller.peer_mgr.myself.features
def is_tor(self):
'''Try to detect if the connection is to a tor hidden service we are
running.'''
tor_proxy = self.controller.peer_mgr.tor_proxy
peer_info = self.peer_info()
return peer_info and peer_info[0] == tor_proxy.ip_addr
async def replaced_banner(self, banner):
network_info = await self.controller.daemon_request('getnetworkinfo')
ni_version = network_info['version']
major, minor = divmod(ni_version, 1000000)
minor, revision = divmod(minor, 10000)
revision //= 100
daemon_version = '{:d}.{:d}.{:d}'.format(major, minor, revision)
for pair in [
('$VERSION', version.VERSION),
('$DAEMON_VERSION', daemon_version),
('$DAEMON_SUBVERSION', network_info['subversion']),
('$DONATION_ADDRESS', self.env.donation_address),
]:
banner = banner.replace(*pair)
return banner
async def banner(self):
'''Return the server banner text.'''
banner = 'Welcome to Electrum!'
if self.is_tor():
banner_file = self.env.tor_banner_file
else:
banner_file = self.env.banner_file
if banner_file:
try:
with codecs.open(banner_file, 'r', 'utf-8') as f:
banner = f.read()
except Exception as e:
self.log_error('reading banner file {}: {}'
.format(banner_file, e))
else:
banner = await self.replaced_banner(banner)
return banner
def server_version(self, client_name=None, protocol_version=None):
'''Returns the server version as a string.
client_name: a string identifying the client
protocol_version: the protocol version spoken by the client
'''
if client_name:
self.client = str(client_name)[:17]
if protocol_version is not None:
self.protocol_version = protocol_version
return version.VERSION
async def transaction_broadcast(self, raw_tx):
'''Broadcast a raw transaction to the network.
raw_tx: the raw transaction as a hexadecimal string'''
# An ugly API: current Electrum clients only pass the raw
# transaction in hex and expect error messages to be returned in
# the result field. And the server shouldn't be doing the client's
# user interface job here.
try:
tx_hash = await self.daemon.sendrawtransaction([raw_tx])
self.txs_sent += 1
self.log_info('sent tx: {}'.format(tx_hash))
self.controller.sent_tx(tx_hash)
return tx_hash
except DaemonError as e:
error = e.args[0]
message = error['message']
self.log_info('sendrawtransaction: {}'.format(message),
throttle=True)
if 'non-mandatory-script-verify-flag' in message:
return (
'Your client produced a transaction that is not accepted '
'by the network any more. Please upgrade to Electrum '
'2.5.1 or newer.'
)
return (
'The transaction was rejected by network rules. ({})\n[{}]'
.format(message, raw_tx)
)
def request_handler(self, method):
'''Return the async handler for the given request method.'''
handler = self.electrumx_handlers.get(method)
if not handler:
handler = self.controller.electrumx_handlers.get(method)
return handler
class LocalRPC(SessionBase):
'''A local TCP RPC server session.'''
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.client = 'RPC'
self.max_send = 0
def request_handler(self, method):
'''Return the async handler for the given request method.'''
return self.controller.rpc_handlers.get(method)