network_dialog.py (19698B)
1 #!/usr/bin/env python 2 # 3 # Electrum - lightweight Bitcoin client 4 # Copyright (C) 2012 thomasv@gitorious 5 # 6 # Permission is hereby granted, free of charge, to any person 7 # obtaining a copy of this software and associated documentation files 8 # (the "Software"), to deal in the Software without restriction, 9 # including without limitation the rights to use, copy, modify, merge, 10 # publish, distribute, sublicense, and/or sell copies of the Software, 11 # and to permit persons to whom the Software is furnished to do so, 12 # subject to the following conditions: 13 # 14 # The above copyright notice and this permission notice shall be 15 # included in all copies or substantial portions of the Software. 16 # 17 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 21 # BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 22 # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 23 # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 # SOFTWARE. 25 26 import socket 27 import time 28 from enum import IntEnum 29 from typing import Tuple, TYPE_CHECKING 30 31 from PyQt5.QtCore import Qt, pyqtSignal, QThread 32 from PyQt5.QtWidgets import (QTreeWidget, QTreeWidgetItem, QMenu, QGridLayout, QComboBox, 33 QLineEdit, QDialog, QVBoxLayout, QHeaderView, QCheckBox, 34 QTabWidget, QWidget, QLabel) 35 from PyQt5.QtGui import QFontMetrics 36 37 from electrum.i18n import _ 38 from electrum import constants, blockchain, util 39 from electrum.interface import ServerAddr, PREFERRED_NETWORK_PROTOCOL 40 from electrum.network import Network 41 from electrum.logging import get_logger 42 43 from .util import (Buttons, CloseButton, HelpButton, read_QIcon, char_width_in_lineedit, 44 PasswordLineEdit) 45 46 if TYPE_CHECKING: 47 from electrum.simple_config import SimpleConfig 48 49 50 _logger = get_logger(__name__) 51 52 protocol_names = ['TCP', 'SSL'] 53 protocol_letters = 'ts' 54 55 class NetworkDialog(QDialog): 56 def __init__(self, network, config, network_updated_signal_obj): 57 QDialog.__init__(self) 58 self.setWindowTitle(_('Network')) 59 self.setMinimumSize(500, 500) 60 self.nlayout = NetworkChoiceLayout(network, config) 61 self.network_updated_signal_obj = network_updated_signal_obj 62 vbox = QVBoxLayout(self) 63 vbox.addLayout(self.nlayout.layout()) 64 vbox.addLayout(Buttons(CloseButton(self))) 65 self.network_updated_signal_obj.network_updated_signal.connect( 66 self.on_update) 67 util.register_callback(self.on_network, ['network_updated']) 68 69 def on_network(self, event, *args): 70 self.network_updated_signal_obj.network_updated_signal.emit(event, args) 71 72 def on_update(self): 73 self.nlayout.update() 74 75 76 77 class NodesListWidget(QTreeWidget): 78 """List of connected servers.""" 79 80 SERVER_ADDR_ROLE = Qt.UserRole + 100 81 CHAIN_ID_ROLE = Qt.UserRole + 101 82 ITEMTYPE_ROLE = Qt.UserRole + 102 83 84 class ItemType(IntEnum): 85 CHAIN = 0 86 CONNECTED_SERVER = 1 87 DISCONNECTED_SERVER = 2 88 TOPLEVEL = 3 89 90 def __init__(self, parent): 91 QTreeWidget.__init__(self) 92 self.parent = parent # type: NetworkChoiceLayout 93 self.setHeaderLabels([_('Server'), _('Height')]) 94 self.setContextMenuPolicy(Qt.CustomContextMenu) 95 self.customContextMenuRequested.connect(self.create_menu) 96 97 def create_menu(self, position): 98 item = self.currentItem() 99 if not item: 100 return 101 item_type = item.data(0, self.ITEMTYPE_ROLE) 102 menu = QMenu() 103 if item_type == self.ItemType.CONNECTED_SERVER: 104 server = item.data(0, self.SERVER_ADDR_ROLE) # type: ServerAddr 105 menu.addAction(_("Use as server"), lambda: self.parent.follow_server(server)) 106 elif item_type == self.ItemType.DISCONNECTED_SERVER: 107 server = item.data(0, self.SERVER_ADDR_ROLE) # type: ServerAddr 108 def func(): 109 self.parent.server_e.setText(server.net_addr_str()) 110 self.parent.set_server() 111 menu.addAction(_("Use as server"), func) 112 elif item_type == self.ItemType.CHAIN: 113 chain_id = item.data(0, self.CHAIN_ID_ROLE) 114 menu.addAction(_("Follow this branch"), lambda: self.parent.follow_branch(chain_id)) 115 else: 116 return 117 menu.exec_(self.viewport().mapToGlobal(position)) 118 119 def keyPressEvent(self, event): 120 if event.key() in [ Qt.Key_F2, Qt.Key_Return, Qt.Key_Enter ]: 121 self.on_activated(self.currentItem(), self.currentColumn()) 122 else: 123 QTreeWidget.keyPressEvent(self, event) 124 125 def on_activated(self, item, column): 126 # on 'enter' we show the menu 127 pt = self.visualItemRect(item).bottomLeft() 128 pt.setX(50) 129 self.customContextMenuRequested.emit(pt) 130 131 def update(self, *, network: Network, servers: dict, use_tor: bool): 132 self.clear() 133 134 # connected servers 135 connected_servers_item = QTreeWidgetItem([_("Connected nodes"), '']) 136 connected_servers_item.setData(0, self.ITEMTYPE_ROLE, self.ItemType.TOPLEVEL) 137 chains = network.get_blockchains() 138 n_chains = len(chains) 139 for chain_id, interfaces in chains.items(): 140 b = blockchain.blockchains.get(chain_id) 141 if b is None: continue 142 name = b.get_name() 143 if n_chains > 1: 144 x = QTreeWidgetItem([name + '@%d'%b.get_max_forkpoint(), '%d'%b.height()]) 145 x.setData(0, self.ITEMTYPE_ROLE, self.ItemType.CHAIN) 146 x.setData(0, self.CHAIN_ID_ROLE, b.get_id()) 147 else: 148 x = connected_servers_item 149 for i in interfaces: 150 star = ' *' if i == network.interface else '' 151 item = QTreeWidgetItem([f"{i.server.to_friendly_name()}" + star, '%d'%i.tip]) 152 item.setData(0, self.ITEMTYPE_ROLE, self.ItemType.CONNECTED_SERVER) 153 item.setData(0, self.SERVER_ADDR_ROLE, i.server) 154 item.setToolTip(0, str(i.server)) 155 x.addChild(item) 156 if n_chains > 1: 157 connected_servers_item.addChild(x) 158 159 # disconnected servers 160 disconnected_servers_item = QTreeWidgetItem([_("Other known servers"), ""]) 161 disconnected_servers_item.setData(0, self.ITEMTYPE_ROLE, self.ItemType.TOPLEVEL) 162 connected_hosts = set([iface.host for ifaces in chains.values() for iface in ifaces]) 163 protocol = PREFERRED_NETWORK_PROTOCOL 164 for _host, d in sorted(servers.items()): 165 if _host in connected_hosts: 166 continue 167 if _host.endswith('.onion') and not use_tor: 168 continue 169 port = d.get(protocol) 170 if port: 171 server = ServerAddr(_host, port, protocol=protocol) 172 item = QTreeWidgetItem([server.net_addr_str(), ""]) 173 item.setData(0, self.ITEMTYPE_ROLE, self.ItemType.DISCONNECTED_SERVER) 174 item.setData(0, self.SERVER_ADDR_ROLE, server) 175 disconnected_servers_item.addChild(item) 176 177 self.addTopLevelItem(connected_servers_item) 178 self.addTopLevelItem(disconnected_servers_item) 179 180 connected_servers_item.setExpanded(True) 181 for i in range(connected_servers_item.childCount()): 182 connected_servers_item.child(i).setExpanded(True) 183 disconnected_servers_item.setExpanded(True) 184 185 # headers 186 h = self.header() 187 h.setStretchLastSection(False) 188 h.setSectionResizeMode(0, QHeaderView.Stretch) 189 h.setSectionResizeMode(1, QHeaderView.ResizeToContents) 190 191 super().update() 192 193 194 class NetworkChoiceLayout(object): 195 196 def __init__(self, network: Network, config: 'SimpleConfig', wizard=False): 197 self.network = network 198 self.config = config 199 self.tor_proxy = None 200 201 self.tabs = tabs = QTabWidget() 202 proxy_tab = QWidget() 203 blockchain_tab = QWidget() 204 tabs.addTab(blockchain_tab, _('Overview')) 205 tabs.addTab(proxy_tab, _('Proxy')) 206 207 fixed_width_hostname = 24 * char_width_in_lineedit() 208 fixed_width_port = 6 * char_width_in_lineedit() 209 210 # Proxy tab 211 grid = QGridLayout(proxy_tab) 212 grid.setSpacing(8) 213 214 # proxy setting 215 self.proxy_cb = QCheckBox(_('Use proxy')) 216 self.proxy_cb.clicked.connect(self.check_disable_proxy) 217 self.proxy_cb.clicked.connect(self.set_proxy) 218 219 self.proxy_mode = QComboBox() 220 self.proxy_mode.addItems(['SOCKS4', 'SOCKS5']) 221 self.proxy_host = QLineEdit() 222 self.proxy_host.setFixedWidth(fixed_width_hostname) 223 self.proxy_port = QLineEdit() 224 self.proxy_port.setFixedWidth(fixed_width_port) 225 self.proxy_user = QLineEdit() 226 self.proxy_user.setPlaceholderText(_("Proxy user")) 227 self.proxy_password = PasswordLineEdit() 228 self.proxy_password.setPlaceholderText(_("Password")) 229 self.proxy_password.setFixedWidth(fixed_width_port) 230 231 self.proxy_mode.currentIndexChanged.connect(self.set_proxy) 232 self.proxy_host.editingFinished.connect(self.set_proxy) 233 self.proxy_port.editingFinished.connect(self.set_proxy) 234 self.proxy_user.editingFinished.connect(self.set_proxy) 235 self.proxy_password.editingFinished.connect(self.set_proxy) 236 237 self.proxy_mode.currentIndexChanged.connect(self.proxy_settings_changed) 238 self.proxy_host.textEdited.connect(self.proxy_settings_changed) 239 self.proxy_port.textEdited.connect(self.proxy_settings_changed) 240 self.proxy_user.textEdited.connect(self.proxy_settings_changed) 241 self.proxy_password.textEdited.connect(self.proxy_settings_changed) 242 243 self.tor_cb = QCheckBox(_("Use Tor Proxy")) 244 self.tor_cb.setIcon(read_QIcon("tor_logo.png")) 245 self.tor_cb.hide() 246 self.tor_cb.clicked.connect(self.use_tor_proxy) 247 248 grid.addWidget(self.tor_cb, 1, 0, 1, 3) 249 grid.addWidget(self.proxy_cb, 2, 0, 1, 3) 250 grid.addWidget(HelpButton(_('Proxy settings apply to all connections: with Electrum servers, but also with third-party services.')), 2, 4) 251 grid.addWidget(self.proxy_mode, 4, 1) 252 grid.addWidget(self.proxy_host, 4, 2) 253 grid.addWidget(self.proxy_port, 4, 3) 254 grid.addWidget(self.proxy_user, 5, 2) 255 grid.addWidget(self.proxy_password, 5, 3) 256 grid.setRowStretch(7, 1) 257 258 # Blockchain Tab 259 grid = QGridLayout(blockchain_tab) 260 msg = ' '.join([ 261 _("Electrum connects to several nodes in order to download block headers and find out the longest blockchain."), 262 _("This blockchain is used to verify the transactions sent by your transaction server.") 263 ]) 264 self.status_label = QLabel('') 265 grid.addWidget(QLabel(_('Status') + ':'), 0, 0) 266 grid.addWidget(self.status_label, 0, 1, 1, 3) 267 grid.addWidget(HelpButton(msg), 0, 4) 268 269 self.autoconnect_cb = QCheckBox(_('Select server automatically')) 270 self.autoconnect_cb.setEnabled(self.config.is_modifiable('auto_connect')) 271 self.autoconnect_cb.clicked.connect(self.set_server) 272 self.autoconnect_cb.clicked.connect(self.update) 273 msg = ' '.join([ 274 _("If auto-connect is enabled, Electrum will always use a server that is on the longest blockchain."), 275 _("If it is disabled, you have to choose a server you want to use. Electrum will warn you if your server is lagging.") 276 ]) 277 grid.addWidget(self.autoconnect_cb, 1, 0, 1, 3) 278 grid.addWidget(HelpButton(msg), 1, 4) 279 280 self.server_e = QLineEdit() 281 self.server_e.setFixedWidth(fixed_width_hostname + fixed_width_port) 282 self.server_e.editingFinished.connect(self.set_server) 283 msg = _("Electrum sends your wallet addresses to a single server, in order to receive your transaction history.") 284 grid.addWidget(QLabel(_('Server') + ':'), 2, 0) 285 grid.addWidget(self.server_e, 2, 1, 1, 3) 286 grid.addWidget(HelpButton(msg), 2, 4) 287 288 self.height_label = QLabel('') 289 msg = _('This is the height of your local copy of the blockchain.') 290 grid.addWidget(QLabel(_('Blockchain') + ':'), 3, 0) 291 grid.addWidget(self.height_label, 3, 1) 292 grid.addWidget(HelpButton(msg), 3, 4) 293 294 self.split_label = QLabel('') 295 grid.addWidget(self.split_label, 4, 0, 1, 3) 296 297 self.nodes_list_widget = NodesListWidget(self) 298 grid.addWidget(self.nodes_list_widget, 6, 0, 1, 5) 299 300 vbox = QVBoxLayout() 301 vbox.addWidget(tabs) 302 self.layout_ = vbox 303 # tor detector 304 self.td = td = TorDetector() 305 td.found_proxy.connect(self.suggest_proxy) 306 td.start() 307 308 self.fill_in_proxy_settings() 309 self.update() 310 311 def check_disable_proxy(self, b): 312 if not self.config.is_modifiable('proxy'): 313 b = False 314 for w in [self.proxy_mode, self.proxy_host, self.proxy_port, self.proxy_user, self.proxy_password]: 315 w.setEnabled(b) 316 317 def enable_set_server(self): 318 if self.config.is_modifiable('server'): 319 enabled = not self.autoconnect_cb.isChecked() 320 self.server_e.setEnabled(enabled) 321 else: 322 for w in [self.autoconnect_cb, self.server_e, self.nodes_list_widget]: 323 w.setEnabled(False) 324 325 def update(self): 326 net_params = self.network.get_parameters() 327 server = net_params.server 328 auto_connect = net_params.auto_connect 329 if not self.server_e.hasFocus(): 330 self.server_e.setText(server.to_friendly_name()) 331 self.autoconnect_cb.setChecked(auto_connect) 332 333 height_str = "%d "%(self.network.get_local_height()) + _('blocks') 334 self.height_label.setText(height_str) 335 n = len(self.network.get_interfaces()) 336 status = _("Connected to {0} nodes.").format(n) if n > 1 else _("Connected to {0} node.").format(n) if n == 1 else _("Not connected") 337 self.status_label.setText(status) 338 chains = self.network.get_blockchains() 339 if len(chains) > 1: 340 chain = self.network.blockchain() 341 forkpoint = chain.get_max_forkpoint() 342 name = chain.get_name() 343 msg = _('Chain split detected at block {0}').format(forkpoint) + '\n' 344 msg += (_('You are following branch') if auto_connect else _('Your server is on branch'))+ ' ' + name 345 msg += ' (%d %s)' % (chain.get_branch_size(), _('blocks')) 346 else: 347 msg = '' 348 self.split_label.setText(msg) 349 self.nodes_list_widget.update(network=self.network, 350 servers=self.network.get_servers(), 351 use_tor=self.tor_cb.isChecked()) 352 self.enable_set_server() 353 354 def fill_in_proxy_settings(self): 355 proxy_config = self.network.get_parameters().proxy 356 if not proxy_config: 357 proxy_config = {"mode": "none", "host": "localhost", "port": "9050"} 358 359 b = proxy_config.get('mode') != "none" 360 self.check_disable_proxy(b) 361 if b: 362 self.proxy_cb.setChecked(True) 363 self.proxy_mode.setCurrentIndex( 364 self.proxy_mode.findText(str(proxy_config.get("mode").upper()))) 365 366 self.proxy_host.setText(proxy_config.get("host")) 367 self.proxy_port.setText(proxy_config.get("port")) 368 self.proxy_user.setText(proxy_config.get("user", "")) 369 self.proxy_password.setText(proxy_config.get("password", "")) 370 371 def layout(self): 372 return self.layout_ 373 374 def follow_branch(self, chain_id): 375 self.network.run_from_another_thread(self.network.follow_chain_given_id(chain_id)) 376 self.update() 377 378 def follow_server(self, server: ServerAddr): 379 self.network.run_from_another_thread(self.network.follow_chain_given_server(server)) 380 self.update() 381 382 def accept(self): 383 pass 384 385 def set_server(self): 386 net_params = self.network.get_parameters() 387 try: 388 server = ServerAddr.from_str_with_inference(str(self.server_e.text())) 389 if not server: raise Exception("failed to parse") 390 except Exception: 391 return 392 net_params = net_params._replace(server=server, 393 auto_connect=self.autoconnect_cb.isChecked()) 394 self.network.run_from_another_thread(self.network.set_parameters(net_params)) 395 396 def set_proxy(self): 397 net_params = self.network.get_parameters() 398 if self.proxy_cb.isChecked(): 399 proxy = { 'mode':str(self.proxy_mode.currentText()).lower(), 400 'host':str(self.proxy_host.text()), 401 'port':str(self.proxy_port.text()), 402 'user':str(self.proxy_user.text()), 403 'password':str(self.proxy_password.text())} 404 else: 405 proxy = None 406 self.tor_cb.setChecked(False) 407 net_params = net_params._replace(proxy=proxy) 408 self.network.run_from_another_thread(self.network.set_parameters(net_params)) 409 410 def suggest_proxy(self, found_proxy): 411 if found_proxy is None: 412 self.tor_cb.hide() 413 return 414 self.tor_proxy = found_proxy 415 self.tor_cb.setText("Use Tor proxy at port " + str(found_proxy[1])) 416 if (self.proxy_cb.isChecked() 417 and self.proxy_mode.currentIndex() == self.proxy_mode.findText('SOCKS5') 418 and self.proxy_host.text() == "127.0.0.1" 419 and self.proxy_port.text() == str(found_proxy[1])): 420 self.tor_cb.setChecked(True) 421 self.tor_cb.show() 422 423 def use_tor_proxy(self, use_it): 424 if not use_it: 425 self.proxy_cb.setChecked(False) 426 else: 427 socks5_mode_index = self.proxy_mode.findText('SOCKS5') 428 if socks5_mode_index == -1: 429 _logger.info("can't find proxy_mode 'SOCKS5'") 430 return 431 self.proxy_mode.setCurrentIndex(socks5_mode_index) 432 self.proxy_host.setText("127.0.0.1") 433 self.proxy_port.setText(str(self.tor_proxy[1])) 434 self.proxy_user.setText("") 435 self.proxy_password.setText("") 436 self.tor_cb.setChecked(True) 437 self.proxy_cb.setChecked(True) 438 self.check_disable_proxy(use_it) 439 self.set_proxy() 440 441 def proxy_settings_changed(self): 442 self.tor_cb.setChecked(False) 443 444 445 class TorDetector(QThread): 446 found_proxy = pyqtSignal(object) 447 448 def __init__(self): 449 QThread.__init__(self) 450 451 def run(self): 452 # Probable ports for Tor to listen at 453 ports = [9050, 9150] 454 while True: 455 for p in ports: 456 net_addr = ("127.0.0.1", p) 457 if TorDetector.is_tor_port(net_addr): 458 self.found_proxy.emit(net_addr) 459 break 460 else: 461 self.found_proxy.emit(None) 462 time.sleep(10) 463 464 @staticmethod 465 def is_tor_port(net_addr: Tuple[str, int]) -> bool: 466 try: 467 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 468 s.settimeout(0.1) 469 s.connect(net_addr) 470 # Tor responds uniquely to HTTP-like requests 471 s.send(b"GET\n") 472 if b"Tor is not an HTTP Proxy" in s.recv(1024): 473 return True 474 except socket.error: 475 pass 476 return False