diff --git a/electrum/gui/messages.py b/electrum/gui/messages.py index d7a66dce1ca..5d8d168949a 100644 --- a/electrum/gui/messages.py +++ b/electrum/gui/messages.py @@ -112,11 +112,12 @@ def to_rtf(msg): MSG_CONNECTMODE_AUTOCONNECT = _('Auto-connect') -MSG_CONNECTMODE_MANUAL = _('Manual server selection') -MSG_CONNECTMODE_ONESERVER = _('Connect only to a single server') +MSG_CONNECTMODE_MANUAL = _('Bookmarked servers') +MSG_CONNECTMODE_ONESERVER = _('Bookmarked servers (strict)') MSG_CONNECTMODE_SERVER_HELP = _( - "Electrum connects to a unique server in order to receive your transaction history. " + "This is the list of servers from wich Electrum may request your transaction history" + "Electrum will pick one server from this list, with the longest blockchain. " "This server will learn your wallet adddresses." ) MSG_CONNECTMODE_NODES_HELP = _( @@ -132,12 +133,13 @@ def to_rtf(msg): ) MSG_CONNECTMODE_MANUAL_HELP = _( - "Electrum will stay with the server you selected. It will warn you if your server is lagging." + "Electrum will use the servers you selected in order to request your wallet history. " + "It will connect to other nodes for block headers and warn you if your server is lagging." ) MSG_CONNECTMODE_ONESERVER_HELP = _( - "Electrum will stay with the server you selected, and it will not connect to additional nodes. " + "Electrum will use the servers you selected inorder to request your history, and it will not connect to additional nodes. " "This will disable fork detection. " - "This mode is only intended for connecting to your own fully trusted server. " - "Using this option on a public server is a security risk and is discouraged." + "This mode is only intended for connecting to a set of fully trusted server. " + "Using this option on a single public server is a security risk and is discouraged." ) diff --git a/electrum/gui/qml/components/ServerConfigDialog.qml b/electrum/gui/qml/components/ServerConfigDialog.qml index 80cc54a3191..e0d2bb19464 100644 --- a/electrum/gui/qml/components/ServerConfigDialog.qml +++ b/electrum/gui/qml/components/ServerConfigDialog.qml @@ -42,9 +42,10 @@ ElDialog { icon.source: '../../icons/confirmed.png' onClicked: { let auto_connect = serverconfig.serverConnectMode == ServerConnectModeComboBox.Mode.Autoconnect - let server = serverconfig.address let one_server = serverconfig.serverConnectMode == ServerConnectModeComboBox.Mode.Single - Network.setServerParameters(server, auto_connect, one_server) + Network.setParameters(auto_connect, one_server) + let server = serverconfig.address + Network.setBookmark(server, true) rootItem.close() } } diff --git a/electrum/gui/qml/qenetwork.py b/electrum/gui/qml/qenetwork.py index c0d8c5957e2..d1ea40c2599 100644 --- a/electrum/gui/qml/qenetwork.py +++ b/electrum/gui/qml/qenetwork.py @@ -92,7 +92,7 @@ def on_event_tor_probed(self, *args): self.proxyTorChanged.emit() def _update_status(self): - server = str(self.network.get_parameters().server) + server = str(self.network.default_server) if self._server != server: self._server = server self.statusChanged.emit() @@ -198,27 +198,29 @@ def autoConnectDefined(self): def server(self): return self._server - @pyqtSlot(str, bool, bool) - def setServerParameters(self, server: str, auto_connect: bool, one_server: bool): + @pyqtSlot(bool, bool) + def setParameters(self, auto_connect: bool, one_server: bool): net_params = self.network.get_parameters() - if server == net_params.server and auto_connect == net_params.auto_connect and one_server == net_params.oneserver: + if auto_connect == net_params.auto_connect and one_server == net_params.oneserver: return - if server != str(net_params.server): - try: - server = ServerAddr.from_str_with_inference(server) - if not server: - raise Exception('failed to parse') - except Exception: - if not auto_connect: - return - server = net_params.server - self.statusChanged.emit() if auto_connect != net_params.auto_connect: self.network.config.NETWORK_AUTO_CONNECT = auto_connect self.autoConnectChanged.emit() net_params = net_params._replace(server=server, auto_connect=auto_connect, oneserver=one_server) self.network.run_from_another_thread(self.network.set_parameters(net_params)) + @pyqtSlot(str, bool) + def setBookmark(self, server: str, add: bool): + try: + server = ServerAddr.from_str_with_inference(server) + if not server: + raise Exception('failed to parse') + except Exception: + print('failed to parse') + return + self.network.set_server_bookmark(server, add=add) + self.statusChanged.emit() + @pyqtProperty(str, notify=statusChanged) def serverWithStatus(self): server = self._server diff --git a/electrum/gui/qt/network_dialog.py b/electrum/gui/qt/network_dialog.py index d068cec706f..72b73e7ec0a 100644 --- a/electrum/gui/qt/network_dialog.py +++ b/electrum/gui/qt/network_dialog.py @@ -89,7 +89,7 @@ class ItemType(IntEnum): followServer = pyqtSignal([ServerAddr], arguments=['server']) followChain = pyqtSignal([str], arguments=['chain_id']) - setServer = pyqtSignal([str], arguments=['server']) + setBookmark = pyqtSignal([ServerAddr, bool], arguments=['server', 'add']) def __init__(self, *, network: Network): QTreeWidget.__init__(self) @@ -106,19 +106,12 @@ def create_menu(self, position): menu = QMenu() if item_type in [self.ItemType.CONNECTED_SERVER, self.ItemType.DISCONNECTED_SERVER]: server = item.data(0, self.SERVER_ADDR_ROLE) # type: ServerAddr - if item_type == self.ItemType.CONNECTED_SERVER: + if self.network.auto_connect or self.network.is_server_bookmarked(server): def do_follow_server(): self.followServer.emit(server) menu.addAction(read_QIcon("chevron-right.png"), _("Use as server"), do_follow_server) - elif item_type == self.ItemType.DISCONNECTED_SERVER: - def do_set_server(): - self.setServer.emit(str(server)) - menu.addAction(read_QIcon("chevron-right.png"), _("Use as server"), do_set_server) - def set_bookmark(*, add: bool): - self.network.set_server_bookmark(server, add=add) - self.update() - + self.setBookmark.emit(server, add) if self.network.is_server_bookmarked(server): menu.addAction(read_QIcon("bookmark_remove.png"), _("Remove from bookmarks"), lambda: set_bookmark(add=False)) else: @@ -404,7 +397,7 @@ def __init__(self, network: Network, parent=None): grid.addWidget(self.connect_combo, 0, 1, 1, 3) self.server_e = QLineEdit() - self.server_e.editingFinished.connect(self.on_server_settings_changed) + self.server_e.editingFinished.connect(self.on_server_edited) grid.addWidget(QLabel(_('Server') + ':'), 1, 0) grid.addWidget(self.server_e, 1, 1, 1, 3) grid.addWidget(HelpButton(messages.MSG_CONNECTMODE_SERVER_HELP), 1, 4) @@ -432,11 +425,7 @@ def __init__(self, network: Network, parent=None): self.nodes_list_widget = NodesListWidget(network=self.network) self.nodes_list_widget.followServer.connect(self.follow_server) self.nodes_list_widget.followChain.connect(self.follow_branch) - - def do_set_server(server): - self.server_e.setText(server) - self.set_server() - self.nodes_list_widget.setServer.connect(do_set_server) + self.nodes_list_widget.setBookmark.connect(self.set_bookmark) self.layout().addWidget(self.nodes_list_widget) self.nodes_list_widget.update() @@ -462,10 +451,9 @@ def on_server_settings_changed(self): if not self.network._was_started: self.update() return - server = self.server_e.text().strip() net_params = self.network.get_parameters() - if server != net_params.server or self.is_auto_connect() != net_params.auto_connect or self.is_one_server() != net_params.oneserver: - self.set_server() + if self.is_auto_connect() != net_params.auto_connect or self.is_one_server() != net_params.oneserver: + self.set_params() def update(self): self.server_e.setEnabled(self.config.cv.NETWORK_SERVER.is_modifiable() and not self.is_auto_connect()) @@ -493,15 +481,19 @@ def update(self): msg += _('Your server is on branch {0} ({1} blocks)').format(name, chain.get_branch_size()) self.split_label.setText(msg) + def update_server_edit(self): + self.server_e.setText(str(self.network.default_server) if self.network.default_server else '') + + @qt_event_listener + def on_event_default_server_changed(self): + self.update_server_edit() + def update_from_config(self): auto_connect = self.config.NETWORK_AUTO_CONNECT one_server = self.config.NETWORK_ONESERVER v = ConnectMode.AUTOCONNECT if auto_connect else ConnectMode.ONESERVER if one_server else ConnectMode.MANUAL self.connect_combo.setCurrentIndex(v) - - server = self.config.NETWORK_SERVER - self.server_e.setText(server) - + self.update_server_edit() self.server_e.setEnabled(self.config.cv.NETWORK_SERVER.is_modifiable() and not auto_connect) self.nodes_list_widget.setEnabled(self.config.cv.NETWORK_SERVER.is_modifiable()) @@ -514,16 +506,33 @@ def follow_server(self, server: ServerAddr): self.network.run_from_another_thread(self.network.follow_chain_given_server(server)) self.update() - def set_server(self): - net_params = self.network.get_parameters() + def on_server_edited(self): try: server = ServerAddr.from_str_with_inference(str(self.server_e.text())) if not server: raise Exception("failed to parse server") except Exception: + self.server_e.setText('') return + self.set_bookmark(server, add=True) + self.follow_server(server) + + def set_bookmark(self, server: ServerAddr, add: bool): + self.network.set_server_bookmark(server, add=add) + if not add and server == self.network.default_server: + self.set_params() + else: + self.nodes_list_widget.update() + self.update() + + def follow_server(self, server: ServerAddr): + self.server_e.setText(str(server)) + self.network.run_from_another_thread(self.network.switch_to_interface(server)) + self.update() + + def set_params(self): + net_params = self.network.get_parameters() net_params = net_params._replace( - server=server, auto_connect=self.is_auto_connect(), oneserver=self.is_one_server(), ) diff --git a/electrum/network.py b/electrum/network.py index e1b41b8a551..f92e24878a4 100644 --- a/electrum/network.py +++ b/electrum/network.py @@ -278,7 +278,7 @@ def __str__(self): class NetworkParameters(NamedTuple): - server: ServerAddr + servers: List[ServerAddr] proxy: ProxySettings auto_connect: bool oneserver: bool = False @@ -590,11 +590,26 @@ def has_fee_mempool(self) -> bool: def requested_fee_estimates(self): self.last_time_fee_estimates_requested = time.time() + def sanitize_servers(self, server_str_list): + servers = [] + for server_str in server_str_list: + try: + server = ServerAddr.from_str_with_inference(server_str) + if not server: + raise Exception(f"failed to parse server {server_str}") + except Exception: + continue + servers.append(server) + return servers + def get_parameters(self) -> NetworkParameters: - return NetworkParameters(server=self.default_server, - proxy=self.proxy, - auto_connect=self.auto_connect, - oneserver=self.oneserver) + servers_str_list = self.config.get_servers() + servers = self.sanitize_servers(servers_str_list) + return NetworkParameters( + servers=servers, + proxy=self.proxy, + auto_connect=self.auto_connect, + oneserver=self.oneserver) def _init_parameters_from_config(self) -> None: dns_hacks.configure_dns_resolver() @@ -651,7 +666,7 @@ def update_fee_estimates(self, *, fee_est: Dict[int, int] = None): @with_recent_servers_lock - def get_servers(self): + def get_servers(self, bookmarks_only=False) -> dict: # note: order of sources when adding servers here is crucial! # don't let "server_peers" overwrite anything, # otherwise main server can eclipse the client @@ -669,8 +684,10 @@ def get_servers(self): out[server.host].update({server.protocol: port}) else: out[server.host] = {server.protocol: port} + if bookmarks_only: + out = dict() # add bookmarks - bookmarks = self.config.NETWORK_BOOKMARKED_SERVERS or [] + bookmarks = self.config.get_servers() for server_str in bookmarks: try: server = ServerAddr.from_str(server_str) @@ -696,18 +713,20 @@ def _get_next_server_to_try(self) -> Optional[ServerAddr]: # Note: with sticky servers, it is more difficult for an attacker to eclipse the client, # however if they succeed, the eclipsing would persist. To try to balance this, # we only give priority to recent_servers up to NUM_STICKY_SERVERS. - with self.recent_servers_lock: - recent_servers = list(self._recent_servers) - recent_servers = [s for s in recent_servers if s.protocol in self._allowed_protocols] - if len(connected_servers & set(recent_servers)) < NUM_STICKY_SERVERS: - for server in recent_servers: - if server in connected_servers: - continue - if not self._can_retry_addr(server, now=now): - continue - return server + + if not self.oneserver: + with self.recent_servers_lock: + recent_servers = list(self._recent_servers) + recent_servers = [s for s in recent_servers if s.protocol in self._allowed_protocols] + if len(connected_servers & set(recent_servers)) < NUM_STICKY_SERVERS: + for server in recent_servers: + if server in connected_servers: + continue + if not self._can_retry_addr(server, now=now): + continue + return server # try all servers we know about, pick one at random - hostmap = self.get_servers() + hostmap = self.get_servers(bookmarks_only=self.oneserver) servers = list(set(filter_protocol(hostmap, allowed_protocols=self._allowed_protocols)) - connected_servers) random.shuffle(servers) for server in servers: @@ -718,22 +737,24 @@ def _get_next_server_to_try(self) -> Optional[ServerAddr]: def _set_default_server(self) -> None: # Server for addresses and transactions - server = self.config.NETWORK_SERVER + servers = self.config.get_servers() # Sanitize default server - if server: + if servers: + server = servers[0] try: self.default_server = ServerAddr.from_str(server) except Exception: self.logger.warning(f'failed to parse server-string ({server!r}); falling back to localhost:1:s.') - self.default_server = ServerAddr.from_str("localhost:1:s") + self.default_server = None #ServerAddr.from_str("localhost:1:s") else: # if oneserver is enabled but no server specified then don't pick a random server - if self.config.NETWORK_ONESERVER: - self.logger.warning(f'"oneserver" option enabled, but no "server" defined; falling back to localhost:1:s.') - self.default_server = ServerAddr.from_str("localhost:1:s") + if not self.config.NETWORK_AUTO_CONNECT: + self.logger.warning(f'"no "server" defined; falling back to localhost:1:s.') + self.default_server = None #ServerAddr.from_str("localhost:1:s") else: self.default_server = pick_random_server(allowed_protocols=self._allowed_protocols) - assert isinstance(self.default_server, ServerAddr), f"invalid type for default_server: {self.default_server!r}" + + #assert isinstance(self.default_server, ServerAddr), f"invalid type for default_server: {self.default_server!r}" def _set_proxy(self, proxy: ProxySettings): if self.proxy == proxy: @@ -769,7 +790,7 @@ async def set_parameters(self, net_params: NetworkParameters): proxy_enabled = proxy.enabled proxy_user = proxy.user proxy_pass = proxy.password - server = net_params.server + servers = net_params.servers # sanitize parameters try: if proxy: @@ -786,9 +807,10 @@ async def set_parameters(self, net_params: NetworkParameters): self.config.NETWORK_PROXY = proxy_str self.config.NETWORK_PROXY_USER = proxy_user self.config.NETWORK_PROXY_PASSWORD = proxy_pass - self.config.NETWORK_SERVER = str(server) + servers_as_str = [str(server) for server in servers] + self.config.set_servers(servers_as_str) # abort if changes were not allowed by config - if self.config.NETWORK_SERVER != str(server) \ + if self.config.get_servers() != servers_as_str \ or self.config.NETWORK_PROXY_ENABLED != proxy_enabled \ or self.config.NETWORK_PROXY != proxy_str \ or self.config.NETWORK_PROXY_USER != proxy_user \ @@ -798,7 +820,7 @@ async def set_parameters(self, net_params: NetworkParameters): proxy_changed = self.proxy != proxy oneserver_changed = self.oneserver != net_params.oneserver - default_server_changed = self.default_server != server + default_server_changed = servers and self.default_server != servers[0] self._init_parameters_from_config() if not self._was_started: return @@ -809,7 +831,9 @@ async def set_parameters(self, net_params: NetworkParameters): await self.stop(full_shutdown=False) await self._start() elif default_server_changed: - await self.switch_to_interface(server) + await self.switch_to_interface(servers[0]) + elif not servers: + await self.switch_to_interface(self.default_server) else: await self.switch_lagging_interface() util.trigger_callback('network_updated') @@ -817,23 +841,23 @@ async def set_parameters(self, net_params: NetworkParameters): def _maybe_set_oneserver(self) -> None: oneserver = self.config.NETWORK_ONESERVER self.oneserver = oneserver - self.num_server = NUM_TARGET_CONNECTED_SERVERS if not oneserver else 0 + self.num_server = NUM_TARGET_CONNECTED_SERVERS def is_server_bookmarked(self, server: ServerAddr) -> bool: - bookmarks = self.config.NETWORK_BOOKMARKED_SERVERS or [] + bookmarks = self.config.get_servers() return str(server) in bookmarks def set_server_bookmark(self, server: ServerAddr, *, add: bool) -> None: server_str = str(server) with self.config.lock: - bookmarks = self.config.NETWORK_BOOKMARKED_SERVERS or [] + bookmarks = self.config.get_servers() if add: if server_str not in bookmarks: bookmarks.append(server_str) else: # remove if server_str in bookmarks: bookmarks.remove(server_str) - self.config.NETWORK_BOOKMARKED_SERVERS = bookmarks + self.config.set_servers(bookmarks) async def _switch_to_random_interface(self): '''Switch to a random connected server other than the current one''' @@ -845,18 +869,20 @@ async def _switch_to_random_interface(self): async def switch_lagging_interface(self): """If auto_connect and lagging, switch interface (only within fork).""" - if self.auto_connect and await self._server_is_lagging(): + if await self._server_is_lagging(): # switch to one that has the correct header (not height) best_header = self.blockchain().header_at_tip() with self.interfaces_lock: interfaces = list(self.interfaces.values()) filtered = list(filter(lambda iface: iface.tip_header == best_header, interfaces)) + if not self.auto_connect: + filtered = [i for i in filtered if i.server in self.config.get_servers()] if filtered: chosen_iface = random.choice(filtered) await self.switch_to_interface(chosen_iface.server) async def switch_unwanted_fork_interface(self) -> None: """If auto_connect, maybe switch to another fork/chain.""" - if not self.auto_connect or not self.interface: + if not self.interface: return with self.interfaces_lock: interfaces = list(self.interfaces.values()) pref_height = self._blockchain_preferred_block['height'] @@ -874,6 +900,8 @@ async def switch_unwanted_fork_interface(self) -> None: # switch to another random interface that is on this fork, if any filtered = [iface for iface in interfaces if iface.blockchain == chain] + if not self.auto_connect: + filtered = [i for i in filtered if i.server in self.config.get_servers()] if filtered: self.logger.info(f"switching to (more) preferred fork (rank {rank})") chosen_iface = random.choice(filtered) @@ -973,6 +1001,8 @@ def get_network_timeout_seconds(self, request_type=NetworkTimeout.Generic) -> in @ignore_exceptions # do not kill outer taskgroup @log_exceptions async def _run_new_interface(self, server: ServerAddr): + if server is None: + return if (server in self.interfaces or server in self._connecting_ifaces or server in self._closing_ifaces): diff --git a/electrum/simple_config.py b/electrum/simple_config.py index 63404a5bca3..75bdd88a576 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -632,14 +632,19 @@ def __setattr__(self, name, value): NETWORK_PROXY_USER = ConfigVar('proxy_user', default=None, type_=str) NETWORK_PROXY_PASSWORD = ConfigVar('proxy_password', default=None, type_=str) NETWORK_PROXY_ENABLED = ConfigVar('enable_proxy', default=lambda config: config.NETWORK_PROXY not in [None, "none"], type_=bool) + NETWORK_SERVER = ConfigVar('server', default=None, type_=str) + def get_servers(self) -> Sequence[str]: + return self.NETWORK_SERVER.split(',') if self.NETWORK_SERVER else [] + def set_servers(self, servers: Sequence[str]): + self.NETWORK_SERVER = ','.join(servers) + NETWORK_NOONION = ConfigVar('noonion', default=False, type_=bool) NETWORK_OFFLINE = ConfigVar('offline', default=False, type_=bool) NETWORK_SKIPMERKLECHECK = ConfigVar('skipmerklecheck', default=False, type_=bool) NETWORK_SERVERFINGERPRINT = ConfigVar('serverfingerprint', default=None, type_=str) NETWORK_MAX_INCOMING_MSG_SIZE = ConfigVar('network_max_incoming_msg_size', default=1_000_000, type_=int) # in bytes NETWORK_TIMEOUT = ConfigVar('network_timeout', default=None, type_=int) - NETWORK_BOOKMARKED_SERVERS = ConfigVar('network_bookmarked_servers', default=None) WALLET_MERGE_DUPLICATE_OUTPUTS = ConfigVar( 'wallet_merge_duplicate_outputs', default=False, type_=bool, diff --git a/electrum/wizard.py b/electrum/wizard.py index de79c12f334..3e5234c6960 100644 --- a/electrum/wizard.py +++ b/electrum/wizard.py @@ -814,7 +814,7 @@ def do_configure_server(self, wizard_data: dict): raise Exception('failed to parse server %s' % wizard_data['server']) except Exception: return - net_params = net_params._replace(server=server, auto_connect=wizard_data['autoconnect'], oneserver=oneserver) + net_params = net_params._replace(servers=[server], auto_connect=wizard_data['autoconnect'], oneserver=oneserver) self._daemon.network.run_from_another_thread(self._daemon.network.set_parameters(net_params)) def do_configure_autoconnect(self, wizard_data: dict): diff --git a/tests/test_wizard.py b/tests/test_wizard.py index 3501625026b..218b8ff8c2b 100644 --- a/tests/test_wizard.py +++ b/tests/test_wizard.py @@ -14,7 +14,7 @@ def __init__(self): def reset(self): self.run_called = False - self.parameters = NetworkParameters(server=None, proxy=None, auto_connect=None, oneserver=None) + self.parameters = NetworkParameters(servers=[], proxy=None, auto_connect=None, oneserver=None) def run_from_another_thread(self, *args, **kwargs): self.run_called = True @@ -88,7 +88,7 @@ async def test_proxy(self): self.assertTrue(w.is_last_view(v.view, d)) self.assertTrue(w._daemon.network.run_called) - self.assertEqual(NetworkParameters(server=None, proxy=ProxySettings.from_dict(d_proxy), auto_connect=True, oneserver=None), w._daemon.network.parameters) + self.assertEqual(NetworkParameters(servers=[], proxy=ProxySettings.from_dict(d_proxy), auto_connect=True, oneserver=None), w._daemon.network.parameters) async def test_proxy_and_server(self): w = ServerConnectWizard(DaemonMock(self.config)) @@ -112,7 +112,7 @@ async def test_proxy_and_server(self): serverobj = ServerAddr.from_str_with_inference('localhost:1:t') self.assertTrue(w._daemon.network.run_called) - self.assertEqual(NetworkParameters(server=serverobj, proxy=None, auto_connect=False, oneserver=False), w._daemon.network.parameters) + self.assertEqual(NetworkParameters(servers=[serverobj], proxy=None, auto_connect=False, oneserver=False), w._daemon.network.parameters) class WalletWizardTestCase(WizardTestCase):