tnetwork: do not connect to multiple servers on same /16 - electrum - Electrum Bitcoin wallet HTML git clone https://git.parazyd.org/electrum DIR Log DIR Files DIR Refs DIR Submodules --- DIR commit d8f3ab0917f4300b59442d5d7fc25f1e4d877143 DIR parent 7ddc28b0dc488a81b7a269a17568b542835b8c43 HTML Author: SomberNight <somber.night@protonmail.com> Date: Fri, 12 Apr 2019 22:32:36 +0200 network: do not connect to multiple servers on same /16 maintain a healthy spread of (IP addresses of) connected servers Diffstat: M electrum/interface.py | 39 +++++++++++++++++++++++++++++++ M electrum/network.py | 36 ++++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) --- DIR diff --git a/electrum/interface.py b/electrum/interface.py t@@ -30,6 +30,7 @@ import traceback import asyncio from typing import Tuple, Union, List, TYPE_CHECKING, Optional from collections import defaultdict +from ipaddress import IPv4Network, IPv6Network, ip_address import aiorpcx from aiorpcx import RPCSession, Notification t@@ -51,6 +52,8 @@ if TYPE_CHECKING: ca_path = certifi.where() +BUCKET_NAME_OF_ONION_SERVERS = 'onion' + class NetworkTimeout: # seconds t@@ -187,6 +190,7 @@ class Interface(PrintError): self.network = network self._set_proxy(proxy) self.session = None # type: NotificationSession + self._ipaddr_bucket = None self.tip_header = None self.tip = 0 t@@ -395,6 +399,9 @@ class Interface(PrintError): return conn, 0 return conn, res['count'] + def is_main_server(self) -> bool: + return self.network.default_server == self.server + async def open_session(self, sslc, exit_early=False): async with aiorpcx.Connector(NotificationSession, host=self.host, port=self.port, t@@ -408,6 +415,9 @@ class Interface(PrintError): raise GracefulDisconnect(e) # probably 'unsupported protocol version' if exit_early: return + if not self.network.check_interface_against_healthy_spread_of_connected_servers(self): + raise GracefulDisconnect(f'too many connected servers already ' + f'in bucket {self.bucket_based_on_ipaddress()}') self.print_error("connection established. version: {}".format(ver)) async with self.group as group: t@@ -604,6 +614,35 @@ class Interface(PrintError): def client_name(cls) -> str: return f'electrum/{version.ELECTRUM_VERSION}' + def is_tor(self): + return self.host.endswith('.onion') + + def ip_addr(self) -> Optional[str]: + session = self.session + if not session: return None + peer_addr = session.peer_address() + if not peer_addr: return None + return peer_addr[0] + + def bucket_based_on_ipaddress(self) -> str: + def do_bucket(): + if self.is_tor(): + return BUCKET_NAME_OF_ONION_SERVERS + ip_addr = ip_address(self.ip_addr()) + if not ip_addr: + return '' + if ip_addr.version == 4: + slash16 = IPv4Network(ip_addr).supernet(prefixlen_diff=32-16) + return str(slash16) + elif ip_addr.version == 6: + slash48 = IPv6Network(ip_addr).supernet(prefixlen_diff=128-48) + return str(slash48) + return '' + + if not self._ipaddr_bucket: + self._ipaddr_bucket = do_bucket() + return self._ipaddr_bucket + def _assert_header_does_not_check_against_any_chain(header: dict) -> None: chain_bad = blockchain.check_header(header) if 'mock' not in header else header['mock']['check'](header) DIR diff --git a/electrum/network.py b/electrum/network.py t@@ -52,7 +52,7 @@ from . import blockchain from . import bitcoin from .blockchain import Blockchain, HEADER_SIZE from .interface import (Interface, serialize_server, deserialize_server, - RequestTimedOut, NetworkTimeout) + RequestTimedOut, NetworkTimeout, BUCKET_NAME_OF_ONION_SERVERS) from .version import PROTOCOL_VERSION from .simple_config import SimpleConfig from .i18n import _ t@@ -756,6 +756,30 @@ class Network(PrintError): self._add_recent_server(server) self.trigger_callback('network_updated') + def check_interface_against_healthy_spread_of_connected_servers(self, iface_to_check) -> bool: + # main interface is exempt. this makes switching servers easier + if iface_to_check.is_main_server(): + return True + # bucket connected interfaces + with self.interfaces_lock: + interfaces = list(self.interfaces.values()) + if iface_to_check in interfaces: + interfaces.remove(iface_to_check) + buckets = defaultdict(list) + for iface in interfaces: + buckets[iface.bucket_based_on_ipaddress()].append(iface) + # check proposed server against buckets + onion_servers = buckets[BUCKET_NAME_OF_ONION_SERVERS] + if iface_to_check.is_tor(): + # keep number of onion servers below half of all connected servers + if len(onion_servers) > NUM_TARGET_CONNECTED_SERVERS // 2: + return False + else: + bucket = iface_to_check.bucket_based_on_ipaddress() + if len(buckets[bucket]) > 0: + return False + return True + async def _init_headers_file(self): b = blockchain.get_best_chain() filename = b.path() t@@ -1149,11 +1173,20 @@ class Network(PrintError): async def maybe_queue_new_interfaces_to_be_launched_later(): now = time.time() for i in range(self.num_server - len(self.interfaces) - len(self.connecting)): + # FIXME this should try to honour "healthy spread of connected servers" self._start_random_interface() if now - self.nodes_retry_time > NODES_RETRY_INTERVAL: self.print_error('network: retrying connections') self.disconnected_servers = set([]) self.nodes_retry_time = now + async def maintain_healthy_spread_of_connected_servers(): + with self.interfaces_lock: interfaces = list(self.interfaces.values()) + random.shuffle(interfaces) + for iface in interfaces: + if not self.check_interface_against_healthy_spread_of_connected_servers(iface): + self.print_error(f"disconnecting from {iface.server}. too many connected " + f"servers already in bucket {iface.bucket_based_on_ipaddress()}") + await self._close_interface(iface) async def maintain_main_interface(): await self._ensure_there_is_a_main_interface() if self.is_connected(): t@@ -1164,6 +1197,7 @@ class Network(PrintError): try: await launch_already_queued_up_new_interfaces() await maybe_queue_new_interfaces_to_be_launched_later() + await maintain_healthy_spread_of_connected_servers() await maintain_main_interface() except asyncio.CancelledError: # suppress spurious cancellations