diff --git a/electrum/base_crash_reporter.py b/electrum/base_crash_reporter.py index a840e6e4a..4574fb6fe 100644 --- a/electrum/base_crash_reporter.py +++ b/electrum/base_crash_reporter.py @@ -25,7 +25,7 @@ import locale import traceback import sys import queue -from typing import NamedTuple, Optional +from typing import TYPE_CHECKING, NamedTuple, Optional from .version import ELECTRUM_VERSION from . import constants @@ -33,6 +33,9 @@ from .i18n import _ from .util import make_aiohttp_session, error_text_str_to_safe_str from .logging import describe_os_version, Logger, get_git_version +if TYPE_CHECKING: + from .network import ProxySettings + class CrashReportResponse(NamedTuple): status: Optional[str] @@ -69,7 +72,7 @@ class BaseCrashReporter(Logger): Logger.__init__(self) self.exc_args = (exctype, value, tb) - def send_report(self, asyncio_loop, proxy, *, timeout=None) -> CrashReportResponse: + def send_report(self, asyncio_loop, proxy: 'ProxySettings', *, timeout=None) -> CrashReportResponse: # FIXME the caller needs to catch generic "Exception", as this method does not have a well-defined API... if constants.net.GENESIS[-4:] not in ["4943", "e26f"] and ".electrum.org" in BaseCrashReporter.report_server: # Gah! Some kind of altcoin wants to send us crash reports. @@ -98,7 +101,7 @@ class BaseCrashReporter(Logger): ) return ret - async def do_post(self, proxy, url, data) -> str: + async def do_post(self, proxy: 'ProxySettings', url, data) -> str: async with make_aiohttp_session(proxy) as session: async with session.post(url, data=data, raise_for_status=True) as resp: return await resp.text() diff --git a/electrum/gui/qml/components/NetworkOverview.qml b/electrum/gui/qml/components/NetworkOverview.qml index 187dd5446..9c41609a0 100644 --- a/electrum/gui/qml/components/NetworkOverview.qml +++ b/electrum/gui/qml/components/NetworkOverview.qml @@ -227,21 +227,21 @@ Pane { color: Material.accentColor } Label { - text: 'mode' in Network.proxy ? qsTr('enabled') : qsTr('disabled') + text: Network.proxy.enabled ? qsTr('enabled') : qsTr('disabled') } Label { - visible: 'mode' in Network.proxy + visible: Network.proxy.enabled text: qsTr('Proxy server:'); color: Material.accentColor } Label { - visible: 'mode' in Network.proxy - text: Network.proxy['host'] ? Network.proxy['host'] + ':' + Network.proxy['port'] : '' + visible: Network.proxy.enabled + text: Network.proxy.host ? Network.proxy.host + ':' + Network.proxy.port : '' } Label { - visible: 'mode' in Network.proxy + visible: Network.proxy.enabled text: qsTr('Proxy type:'); color: Material.accentColor } @@ -253,8 +253,8 @@ Pane { source: '../../icons/tor_logo.png' } Label { - visible: 'mode' in Network.proxy - text: Network.isProxyTor ? 'TOR' : (Network.proxy['mode'] || '') + visible: Network.proxy.enabled + text: Network.isProxyTor ? 'TOR' : (Network.proxy.mode || '') } } diff --git a/electrum/gui/qml/components/ProxyConfigDialog.qml b/electrum/gui/qml/components/ProxyConfigDialog.qml index 93e6d86d5..c3d6bf31b 100644 --- a/electrum/gui/qml/components/ProxyConfigDialog.qml +++ b/electrum/gui/qml/components/ProxyConfigDialog.qml @@ -37,12 +37,7 @@ ElDialog { text: qsTr('Ok') icon.source: '../../icons/confirmed.png' onClicked: { - var proxy = proxyconfig.toProxyDict() - if (proxy && proxy['enabled'] == true) { - Network.proxy = proxy - } else { - Network.proxy = {'enabled': false} - } + Network.proxy = proxyconfig.toProxyDict() rootItem.close() } } @@ -52,17 +47,13 @@ ElDialog { Component.onCompleted: { var p = Network.proxy - if ('mode' in p) { - proxyconfig.proxy_enabled = true - proxyconfig.proxy_address = p['host'] - proxyconfig.proxy_port = p['port'] - proxyconfig.username = p['user'] - proxyconfig.password = p['password'] - proxyconfig.proxy_type = proxyconfig.proxy_type_map.map(function(x) { - return x.value - }).indexOf(p['mode']) - } else { - proxyconfig.proxy_enabled = false - } + proxyconfig.proxy_enabled = p['enabled'] + proxyconfig.proxy_address = p['host'] + proxyconfig.proxy_port = p['port'] + proxyconfig.username = p['user'] + proxyconfig.password = p['password'] + proxyconfig.proxy_type = proxyconfig.proxy_type_map.map(function(x) { + return x.value + }).indexOf(p['mode']) } } diff --git a/electrum/gui/qml/components/controls/OnchainNetworkStatusIndicator.qml b/electrum/gui/qml/components/controls/OnchainNetworkStatusIndicator.qml index 1c3bc4c1e..18f2c2b68 100644 --- a/electrum/gui/qml/components/controls/OnchainNetworkStatusIndicator.qml +++ b/electrum/gui/qml/components/controls/OnchainNetworkStatusIndicator.qml @@ -10,7 +10,7 @@ Image { property bool lagging: connected && Network.isLagging property bool fork: connected && Network.chaintips > 1 property bool syncing: connected && Daemon.currentWallet && Daemon.currentWallet.synchronizing - property bool proxy: connected && 'mode' in Network.proxy && Network.proxy.mode + property bool proxy: connected && Network.proxy.enabled // ?: in order to keep this a binding.. source: Qt.resolvedUrl(!connected diff --git a/electrum/gui/qml/components/controls/ProxyConfig.qml b/electrum/gui/qml/components/controls/ProxyConfig.qml index 711d759db..05bc05b2b 100644 --- a/electrum/gui/qml/components/controls/ProxyConfig.qml +++ b/electrum/gui/qml/components/controls/ProxyConfig.qml @@ -19,17 +19,17 @@ Item { { text: qsTr('SOCKS4'), value: 'socks4' } ] + property bool _probing: false + function toProxyDict() { var p = {} p['enabled'] = pc.proxy_enabled - if (pc.proxy_enabled) { - var type = proxy_type_map[pc.proxy_type]['value'] - p['mode'] = type - p['host'] = pc.proxy_address - p['port'] = pc.proxy_port - p['user'] = pc.username - p['password'] = pc.password - } + var type = proxy_type_map[pc.proxy_type]['value'] + p['mode'] = type + p['host'] = pc.proxy_address + p['port'] = pc.proxy_port + p['user'] = pc.username + p['password'] = pc.password return p } @@ -51,15 +51,6 @@ Item { textRole: 'text' valueRole: 'value' model: proxy_type_map - - onCurrentIndexChanged: { - if (currentIndex == 0) { - if (address.text == '' || port.text == '') { - address.text = "127.0.0.1" - port.text = "9050" - } - } - } } GridLayout { @@ -109,5 +100,44 @@ Item { enabled: proxy_enabled_cb.checked } } + + Pane { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: constants.paddingLarge + padding: 0 + background: Rectangle { + color: constants.darkerDialogBackground + } + FlatButton { + enabled: proxy_enabled_cb.checked && !_probing + text: qsTr('Detect Tor proxy') + onClicked: { + _probing = true + Network.probeTor() + } + } + } + + BusyIndicator { + id: spinner + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: constants.paddingSmall + Layout.preferredWidth: constants.iconSizeXLarge + Layout.preferredHeight: constants.iconSizeXLarge + running: visible + visible: _probing + } + } + + Connections { + target: Network + function onTorProbeFinished(host, port) { + _probing = false + if (host && port) { + proxytype.currentIndex = 0 + proxy_port = ""+port + proxy_address = host + } + } } } diff --git a/electrum/gui/qml/qenetwork.py b/electrum/gui/qml/qenetwork.py index bc574af76..204966d89 100644 --- a/electrum/gui/qml/qenetwork.py +++ b/electrum/gui/qml/qenetwork.py @@ -1,9 +1,10 @@ from typing import TYPE_CHECKING -from PyQt6.QtCore import pyqtProperty, pyqtSignal, QObject +from PyQt6.QtCore import pyqtProperty, pyqtSignal, QObject, pyqtSlot from electrum.logging import get_logger from electrum import constants +from electrum.network import ProxySettings from electrum.interface import ServerAddr from electrum.fee_policy import FEERATE_DEFAULT_RELAY @@ -24,6 +25,7 @@ class QENetwork(QObject, QtEventListener): serverHeightChanged = pyqtSignal([int], arguments=['height']) proxySet = pyqtSignal() proxyChanged = pyqtSignal() + torProbeFinished = pyqtSignal([str, int], arguments=['host', 'port']) statusChanged = pyqtSignal() feeHistogramUpdated = pyqtSignal() chaintipsChanged = pyqtSignal() @@ -237,14 +239,14 @@ class QENetwork(QObject, QtEventListener): @pyqtProperty('QVariantMap', notify=proxyChanged) def proxy(self): net_params = self.network.get_parameters() - return net_params.proxy if net_params.proxy else {} + proxy = net_params.proxy + return proxy.to_dict() @proxy.setter - def proxy(self, proxy_settings): + def proxy(self, proxy_dict): net_params = self.network.get_parameters() - if not proxy_settings['enabled']: - proxy_settings = None - net_params = net_params._replace(proxy=proxy_settings) + proxy = ProxySettings.from_dict(proxy_dict) + net_params = net_params._replace(proxy=proxy) self.network.run_from_another_thread(self.network.set_parameters(net_params)) self.proxyChanged.emit() @@ -273,3 +275,7 @@ class QENetwork(QObject, QtEventListener): if self._serverListModel is None: self._serverListModel = QEServerListModel(self.network) return self._serverListModel + + @pyqtSlot() + def probeTor(self): + ProxySettings.probe_tor(self.torProbeFinished.emit) # via signal diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py index 53cd67f16..93cea0652 100644 --- a/electrum/gui/qt/__init__.py +++ b/electrum/gui/qt/__init__.py @@ -256,7 +256,6 @@ class ElectrumGui(BaseElectrumGui, Logger): window.clean_up() if self.network_dialog: self.network_dialog.close() - self.network_dialog.clean_up() self.network_dialog = None if self.lightning_dialog: self.lightning_dialog.close() @@ -294,16 +293,13 @@ class ElectrumGui(BaseElectrumGui, Logger): self.lightning_dialog = LightningDialog(self) self.lightning_dialog.bring_to_top() - def show_network_dialog(self): + def show_network_dialog(self, proxy_tab=False): if self.network_dialog: - self.network_dialog.on_event_network_updated() - self.network_dialog.show() + self.network_dialog.show(proxy_tab=proxy_tab) self.network_dialog.raise_() return - self.network_dialog = NetworkDialog( - network=self.daemon.network, - config=self.config) - self.network_dialog.show() + self.network_dialog = NetworkDialog(network=self.daemon.network) + self.network_dialog.show(proxy_tab=proxy_tab) def _create_window_for_wallet(self, wallet): w = ElectrumWindow(self, wallet) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 2b1dadbbb..153b86726 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -1005,12 +1005,12 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): if self.fx.is_enabled(): balance_text += self.fx.get_fiat_status_text(balance, self.base_unit(), self.get_decimal_point()) or '' - if not self.network.proxy: + if not self.network.proxy or not self.network.proxy.enabled: icon = read_QIcon("status_connected%s.png"%fork_str) else: icon = read_QIcon("status_connected_proxy%s.png"%fork_str) else: - if self.network.proxy: + if self.network.proxy and self.network.proxy.enabled: network_text = "{} ({})".format(_("Not connected"), _("proxy enabled")) else: network_text = _("Not connected") @@ -1763,7 +1763,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): self.tor_button = StatusBarButton( read_QIcon("tor_logo.png"), _("Tor"), - self.gui_object.show_network_dialog, + partial(self.gui_object.show_network_dialog, proxy_tab=True), sb_height, ) sb.addPermanentWidget(self.tor_button) diff --git a/electrum/gui/qt/network_dialog.py b/electrum/gui/qt/network_dialog.py index 8bc3c032c..43ce7a716 100644 --- a/electrum/gui/qt/network_dialog.py +++ b/electrum/gui/qt/network_dialog.py @@ -24,25 +24,24 @@ # SOFTWARE. from enum import IntEnum -import threading -from PyQt6.QtCore import Qt, pyqtSignal, QThread -from PyQt6.QtWidgets import (QTreeWidget, QTreeWidgetItem, QMenu, QGridLayout, QComboBox, - QLineEdit, QDialog, QVBoxLayout, QHeaderView, QCheckBox, - QTabWidget, QWidget, QLabel) +from PyQt6.QtCore import Qt, pyqtSignal, pyqtSlot +from PyQt6.QtWidgets import ( + QTreeWidget, QTreeWidgetItem, QMenu, QGridLayout, QComboBox, QLineEdit, QDialog, QVBoxLayout, QHeaderView, + QCheckBox, QTabWidget, QWidget, QLabel, QPushButton, QHBoxLayout +) from PyQt6.QtGui import QIntValidator from electrum.i18n import _ from electrum import blockchain from electrum.interface import ServerAddr, PREFERRED_NETWORK_PROTOCOL -from electrum.network import Network +from electrum.network import Network, ProxySettings, is_valid_host, is_valid_port from electrum.logging import get_logger -from electrum.util import detect_tor_socks_proxy -from electrum.simple_config import SimpleConfig -from .util import (Buttons, CloseButton, HelpButton, read_QIcon, char_width_in_lineedit, - PasswordLineEdit) -from .util import QtEventListener, qt_event_listener +from .util import ( + Buttons, CloseButton, HelpButton, read_QIcon, char_width_in_lineedit, PasswordLineEdit, QtEventListener, + qt_event_listener, Spinner +) _logger = get_logger(__name__) @@ -52,32 +51,23 @@ protocol_letters = 'ts' class NetworkDialog(QDialog, QtEventListener): - def __init__(self, *, network: Network, config: 'SimpleConfig'): + def __init__(self, *, network: Network): QDialog.__init__(self) self.setWindowTitle(_('Network')) self.setMinimumSize(500, 500) - self.nlayout = NetworkChoiceLayout(network, config) + self.tabs = tabs = QTabWidget() + self._blockchain_tab = blockchain_tab = ServerWidget(network) + self._proxy_tab = proxy_tab = ProxyWidget(network) + tabs.addTab(blockchain_tab, _('Overview')) + tabs.addTab(proxy_tab, _('Proxy')) + vbox = QVBoxLayout(self) - vbox.addLayout(self.nlayout.layout()) + vbox.addWidget(self.tabs) vbox.addLayout(Buttons(CloseButton(self))) - self.register_callbacks() - self._cleaned_up = False - def show(self): + def show(self, *, proxy_tab: bool = False): super().show() - if td := self.nlayout.td: - td.trigger_rescan() - - @qt_event_listener - def on_event_network_updated(self): - self.nlayout.update() - - def clean_up(self): - if self._cleaned_up: - return - self._cleaned_up = True - self.nlayout.clean_up() - self.unregister_callbacks() + self.tabs.setCurrentWidget(self._proxy_tab if proxy_tab else self._blockchain_tab) class NodesListWidget(QTreeWidget): @@ -227,375 +217,172 @@ class NodesListWidget(QTreeWidget): super().update() -class NetworkChoiceLayout(object): - # TODO consolidate to ProxyWidget+ServerWidget - # TODO TorDetector is unnecessary, Network tests socks5 peer and detects Tor - # TODO apply on editingFinished is not ideal, separate Apply button and on Close? - def __init__(self, network: Network, config: 'SimpleConfig', wizard=False): +class ProxyWidget(QWidget): + PROXY_MODES = { + 'socks4': 'SOCKS4', + 'socks5': 'SOCKS5/TOR' + } + + torProbeFinished = pyqtSignal([str, int], arguments=['host', 'port']) + + def __init__(self, network: Network, parent=None): + super().__init__(parent) self.network = network - self.config = config - self.tor_proxy = None + self.config = network.config - self.tabs = tabs = QTabWidget() - self._proxy_tab = proxy_tab = QWidget() - blockchain_tab = QWidget() - tabs.addTab(blockchain_tab, _('Overview')) - tabs.addTab(proxy_tab, _('Proxy')) - tabs.currentChanged.connect(self._on_tab_changed) - - fixed_width_hostname = 24 * char_width_in_lineedit() fixed_width_port = 6 * char_width_in_lineedit() - # Proxy tab - grid = QGridLayout(proxy_tab) - grid.setSpacing(8) - - # proxy setting + # proxy setting. self.proxy_cb = QCheckBox(_('Use proxy')) - self.proxy_cb.clicked.connect(self.check_disable_proxy) - self.proxy_cb.clicked.connect(self.set_proxy) - self.proxy_mode = QComboBox() - self.proxy_mode.addItems(['SOCKS4', 'SOCKS5']) + for k, v in self.PROXY_MODES.items(): + self.proxy_mode.addItem(v, k) + self.proxy_mode.setCurrentIndex(1) self.proxy_host = QLineEdit() - self.proxy_host.setFixedWidth(fixed_width_hostname) self.proxy_port = QLineEdit() self.proxy_port.setFixedWidth(fixed_width_port) self.proxy_port_validator = QIntValidator(1, 65535) self.proxy_port.setValidator(self.proxy_port_validator) self.proxy_user = QLineEdit() - self.proxy_user.setPlaceholderText(_("Proxy user")) + self.proxy_user.setPlaceholderText(_("Proxy username")) self.proxy_password = PasswordLineEdit() - self.proxy_password.setPlaceholderText(_("Password")) - self.proxy_password.setFixedWidth(fixed_width_port) - - self.proxy_mode.currentIndexChanged.connect(self.set_proxy) - self.proxy_host.editingFinished.connect(self.set_proxy) - self.proxy_port.editingFinished.connect(self.set_proxy) - self.proxy_user.editingFinished.connect(self.set_proxy) - self.proxy_password.editingFinished.connect(self.set_proxy) - - self.proxy_mode.currentIndexChanged.connect(self.proxy_settings_changed) - self.proxy_host.textEdited.connect(self.proxy_settings_changed) - self.proxy_port.textEdited.connect(self.proxy_settings_changed) - self.proxy_user.textEdited.connect(self.proxy_settings_changed) - self.proxy_password.textEdited.connect(self.proxy_settings_changed) - - self.tor_cb = QCheckBox(_("Use Tor Proxy")) - self.tor_cb.setIcon(read_QIcon("tor_logo.png")) - self.tor_cb.hide() - self.tor_cb.clicked.connect(self.use_tor_proxy) - - grid.addWidget(self.tor_cb, 1, 0, 1, 3) - grid.addWidget(self.proxy_cb, 2, 0, 1, 3) - grid.addWidget(HelpButton(_('Proxy settings apply to all connections: with Electrum servers, but also with third-party services.')), 2, 4) - grid.addWidget(self.proxy_mode, 4, 1) - grid.addWidget(self.proxy_host, 4, 2) - grid.addWidget(self.proxy_port, 4, 3) - grid.addWidget(self.proxy_user, 5, 2) - grid.addWidget(self.proxy_password, 5, 3) - grid.setRowStretch(7, 1) - - # Blockchain Tab - grid = QGridLayout(blockchain_tab) - msg = ' '.join([ - _("Electrum connects to several nodes in order to download block headers and find out the longest blockchain."), - _("This blockchain is used to verify the transactions sent by your transaction server.") - ]) - self.status_label = QLabel('') - grid.addWidget(QLabel(_('Status') + ':'), 0, 0) - grid.addWidget(self.status_label, 0, 1, 1, 3) - grid.addWidget(HelpButton(msg), 0, 4) - - self.autoconnect_cb = QCheckBox(_('Select server automatically')) - self.autoconnect_cb.setEnabled(self.config.cv.NETWORK_AUTO_CONNECT.is_modifiable()) - self.autoconnect_cb.clicked.connect(self.set_server) - self.autoconnect_cb.clicked.connect(self.update) - msg = ' '.join([ - _("If auto-connect is enabled, Electrum will always use a server that is on the longest blockchain."), - _("If it is disabled, you have to choose a server you want to use. Electrum will warn you if your server is lagging.") - ]) - grid.addWidget(self.autoconnect_cb, 1, 0, 1, 3) - grid.addWidget(HelpButton(msg), 1, 4) - - self.server_e = QLineEdit() - self.server_e.setFixedWidth(fixed_width_hostname + fixed_width_port) - self.server_e.editingFinished.connect(self.set_server) - msg = _("Electrum sends your wallet addresses to a single server, in order to receive your transaction history.") - grid.addWidget(QLabel(_('Server') + ':'), 2, 0) - grid.addWidget(self.server_e, 2, 1, 1, 3) - grid.addWidget(HelpButton(msg), 2, 4) - - self.height_label = QLabel('') - msg = _('This is the height of your local copy of the blockchain.') - grid.addWidget(QLabel(_('Blockchain') + ':'), 3, 0) - grid.addWidget(self.height_label, 3, 1) - grid.addWidget(HelpButton(msg), 3, 4) - - self.split_label = QLabel('') - grid.addWidget(self.split_label, 4, 0, 1, 3) - - 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) - grid.addWidget(self.nodes_list_widget, 6, 0, 1, 5) - - vbox = QVBoxLayout() - vbox.addWidget(tabs) - self.layout_ = vbox - # tor detector - self.td = td = TorDetector() - td.found_proxy.connect(self.suggest_proxy) - td.start() - - self.fill_in_proxy_settings() - self.update() - - def clean_up(self): - if self.td: - self.td.found_proxy.disconnect() - self.td.stop() - self.td = None - - def check_disable_proxy(self, b): - if not self.config.cv.NETWORK_PROXY.is_modifiable(): - b = False - for w in [self.proxy_mode, self.proxy_host, self.proxy_port, self.proxy_user, self.proxy_password]: - w.setEnabled(b) - - def enable_set_server(self): - if self.config.cv.NETWORK_SERVER.is_modifiable(): - enabled = not self.autoconnect_cb.isChecked() - self.server_e.setEnabled(enabled) - else: - for w in [self.autoconnect_cb, self.server_e, self.nodes_list_widget]: - w.setEnabled(False) - - def update(self): - net_params = self.network.get_parameters() - server = net_params.server - auto_connect = net_params.auto_connect - if not self.server_e.hasFocus(): - self.server_e.setText(server.to_friendly_name()) - self.autoconnect_cb.setChecked(auto_connect) - - height_str = "%d "%(self.network.get_local_height()) + _('blocks') - self.height_label.setText(height_str) - self.status_label.setText(self.network.get_status()) - chains = self.network.get_blockchains() - if len(chains) > 1: - chain = self.network.blockchain() - forkpoint = chain.get_max_forkpoint() - name = chain.get_name() - msg = _('Chain split detected at block {0}').format(forkpoint) + '\n' - msg += (_('You are following branch') if auto_connect else _('Your server is on branch'))+ ' ' + name - msg += ' (%d %s)' % (chain.get_branch_size(), _('blocks')) - else: - msg = '' - self.split_label.setText(msg) - self.nodes_list_widget.update() - self.enable_set_server() - - def fill_in_proxy_settings(self): - proxy_config = self.network.get_parameters().proxy - if not proxy_config: - proxy_config = {"mode": "none", "host": "localhost", "port": "9050"} - - b = proxy_config.get('mode') != "none" - self.check_disable_proxy(b) - if b: - self.proxy_cb.setChecked(True) - self.proxy_mode.setCurrentIndex( - self.proxy_mode.findText(str(proxy_config.get("mode").upper()))) - - self.proxy_host.setText(proxy_config.get("host")) - self.proxy_port.setText(proxy_config.get("port")) - self.proxy_user.setText(proxy_config.get("user", "")) - self.proxy_password.setText(proxy_config.get("password", "")) - - def layout(self): - return self.layout_ - - def follow_branch(self, chain_id): - self.network.run_from_another_thread(self.network.follow_chain_given_id(chain_id)) - self.update() - - def follow_server(self, server: ServerAddr): - self.network.run_from_another_thread(self.network.follow_chain_given_server(server)) - self.update() - - def accept(self): - pass - - def set_server(self): - net_params = self.network.get_parameters() - try: - server = ServerAddr.from_str_with_inference(str(self.server_e.text())) - if not server: raise Exception("failed to parse") - except Exception: - return - net_params = net_params._replace(server=server, - auto_connect=self.autoconnect_cb.isChecked()) - self.network.run_from_another_thread(self.network.set_parameters(net_params)) - - def set_proxy(self): - net_params = self.network.get_parameters() - if self.proxy_cb.isChecked(): - if not self.proxy_port.hasAcceptableInput(): - return - proxy = {'mode':str(self.proxy_mode.currentText()).lower(), - 'host':str(self.proxy_host.text()), - 'port':str(self.proxy_port.text()), - 'user':str(self.proxy_user.text()), - 'password':str(self.proxy_password.text())} - else: - proxy = None - self.tor_cb.setChecked(False) - net_params = net_params._replace(proxy=proxy) - self.network.run_from_another_thread(self.network.set_parameters(net_params)) - - def _on_tab_changed(self): - if self.tabs.currentWidget() is self._proxy_tab: - self.td.trigger_rescan() - - def suggest_proxy(self, found_proxy): - if found_proxy is None: - self.tor_cb.hide() - return - self.tor_proxy = found_proxy - self.tor_cb.setText(_("Use Tor proxy at port {}").format(str(found_proxy[1]))) - if (self.proxy_cb.isChecked() - and self.proxy_mode.currentIndex() == self.proxy_mode.findText('SOCKS5') - and self.proxy_host.text() == "127.0.0.1" - and self.proxy_port.text() == str(found_proxy[1])): - self.tor_cb.setChecked(True) - self.tor_cb.show() - - def use_tor_proxy(self, use_it): - if not use_it: - self.proxy_cb.setChecked(False) - else: - socks5_mode_index = self.proxy_mode.findText('SOCKS5') - if socks5_mode_index == -1: - _logger.info("can't find proxy_mode 'SOCKS5'") - return - self.proxy_mode.setCurrentIndex(socks5_mode_index) - self.proxy_host.setText("127.0.0.1") - self.proxy_port.setText(str(self.tor_proxy[1])) - self.proxy_user.setText("") - self.proxy_password.setText("") - self.tor_cb.setChecked(True) - self.proxy_cb.setChecked(True) - self.check_disable_proxy(use_it) - self.set_proxy() - - def proxy_settings_changed(self): - self.tor_cb.setChecked(False) - - -class TorDetector(QThread): - found_proxy = pyqtSignal(object) - - def __init__(self): - QThread.__init__(self) - self._work_to_do_evt = threading.Event() - self._stopping = False - - def run(self): - while True: - # do rescan - net_addr = detect_tor_socks_proxy() - self.found_proxy.emit(net_addr) - # wait until triggered - self._work_to_do_evt.wait() - self._work_to_do_evt.clear() - if self._stopping: - return - - def trigger_rescan(self) -> None: - self._work_to_do_evt.set() - - def stop(self): - self._stopping = True - self._work_to_do_evt.set() - self.exit() - self.wait() - - -class ProxyWidget(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - - fixed_width_hostname = 24 * char_width_in_lineedit() - fixed_width_port = 6 * char_width_in_lineedit() + self.proxy_password.setPlaceholderText(_("Proxy password")) grid = QGridLayout(self) grid.setSpacing(8) - # proxy setting. - self.proxy_cb = QCheckBox(_('Use proxy')) - self.proxy_mode = QComboBox() - self.proxy_mode.addItems(['SOCKS4', 'SOCKS5']) - self.proxy_mode.setCurrentIndex(1) - self.proxy_host = QLineEdit() - self.proxy_host.setFixedWidth(fixed_width_hostname) - self.proxy_port = QLineEdit() - self.proxy_port.setFixedWidth(fixed_width_port) - self.proxy_user = QLineEdit() - self.proxy_user.setPlaceholderText(_("Proxy user")) - self.proxy_password = PasswordLineEdit() - self.proxy_password.setPlaceholderText(_("Password")) - self.proxy_password.setFixedWidth(fixed_width_port) + grid.addWidget(self.proxy_cb, 0, 0, 1, 4) + proxy_helpbutton = HelpButton( + _('Proxy settings apply to all connections: with Electrum servers, but also with third-party services.')) + grid.addWidget(proxy_helpbutton, 0, 4, alignment=Qt.AlignmentFlag.AlignRight) + grid.addWidget(self.proxy_mode, 1, 0, 1, 1) + grid.addWidget(self.proxy_host, 1, 1, 1, 3) + grid.addWidget(self.proxy_port, 1, 4, 1, 1) + grid.addWidget(self.proxy_user, 2, 1, 1, 2) + grid.addWidget(self.proxy_password, 2, 3, 1, 2) - grid.addWidget(self.proxy_cb, 0, 0, 1, 3) - grid.addWidget(HelpButton(_('Proxy settings apply to all connections: with Electrum servers, but also with third-party services.')), 0, 4) - grid.addWidget(self.proxy_mode, 1, 1) - grid.addWidget(self.proxy_host, 1, 2) - grid.addWidget(self.proxy_port, 1, 3) - grid.addWidget(self.proxy_user, 2, 2) - grid.addWidget(self.proxy_password, 2, 3) + detect_l = QHBoxLayout() + self.detect_button = QPushButton(_('Detect Tor proxy')) + self.spinner = Spinner() + self.spinner.setMargin(5) + detect_l.addWidget(self.detect_button) + detect_l.addWidget(self.spinner) - def get_proxy_settings(self): - return { - 'enabled': self.proxy_cb.isChecked(), - 'mode': ['socks4', 'socks5'][self.proxy_mode.currentIndex()], - 'host': self.proxy_host.text(), - 'port': self.proxy_port.text(), - 'user': self.proxy_user.text(), - 'password': self.proxy_password.text() - } + grid.addLayout(detect_l, 3, 0, 1, 5, alignment=Qt.AlignmentFlag.AlignLeft) + + spacer = QVBoxLayout() + spacer.addStretch(1) + grid.addLayout(spacer, 4, 0, 1, 5) + + self.update_from_config() + self.update() + + # connect signal handlers after init from config + self.proxy_cb.stateChanged.connect(self.on_proxy_enable_toggle) + self.proxy_mode.currentIndexChanged.connect(self.on_proxy_settings_changed) + self.proxy_host.editingFinished.connect(self.on_proxy_settings_changed) + self.proxy_port.editingFinished.connect(self.on_proxy_settings_changed) + self.proxy_user.editingFinished.connect(self.on_proxy_settings_changed) + self.proxy_password.editingFinished.connect(self.on_proxy_settings_changed) + self.detect_button.clicked.connect(self.detect_tor) + + self.torProbeFinished.connect(self.on_tor_probe_finished) + + def update(self): + enabled = self.proxy_cb.isChecked() and self.config.cv.NETWORK_PROXY.is_modifiable() + for item in [ + self.proxy_mode, self.proxy_host, self.proxy_port, self.proxy_user, self.proxy_password, + self.detect_button + ]: + item.setEnabled(enabled) + + if not self.proxy_port.hasAcceptableInput() and not is_valid_port(self.proxy_port.text()): + return + + if not is_valid_host(self.proxy_host.text()): + return + + net_params = self.network.get_parameters() + proxy = self.get_proxy_settings() + net_params = net_params._replace(proxy=proxy) + self.network.run_from_another_thread(self.network.set_parameters(net_params)) + + def update_from_config(self): + proxy = ProxySettings.from_config(self.config) + self.proxy_cb.setChecked(proxy.enabled) + self.proxy_mode.setCurrentText(self.PROXY_MODES.get(proxy.mode)) + self.proxy_host.setText(proxy.host) + self.proxy_port.setText(proxy.port) + self.proxy_user.setText(proxy.user) + self.proxy_password.setText(proxy.password) + + if not self.config.cv.NETWORK_PROXY.is_modifiable(): + for w in [ + self.proxy_cb, self.proxy_mode, self.proxy_host, self.proxy_port, + self.proxy_user, self.proxy_password, self.detect_button + ]: + w.setEnabled(False) + + def on_proxy_enable_toggle(self): + # probe if enabled and no pre-existing settings + # if self.proxy_cb.isChecked() and (not self.proxy_host.text() or not self.proxy_port.text()): + # self.detect_tor() + self.update() + + def on_proxy_settings_changed(self): + self.update() + + def get_proxy_settings(self) -> ProxySettings: + proxy = ProxySettings() + proxy.enabled = self.proxy_cb.isChecked() + proxy.mode = self.proxy_mode.currentData() + proxy.host = self.proxy_host.text() + proxy.port = self.proxy_port.text() + proxy.user = self.proxy_user.text() + proxy.password = self.proxy_password.text() + return proxy + + def detect_tor(self): + self.detect_button.setEnabled(False) + self.spinner.setVisible(True) + ProxySettings.probe_tor(self.torProbeFinished.emit) # via signal + + @pyqtSlot(str, int) + def on_tor_probe_finished(self, host: str, port: int): + self.detect_button.setEnabled(True) + self.spinner.setVisible(False) + if host: + self.proxy_mode.setCurrentIndex(1) + self.proxy_host.setText(host) + self.proxy_port.setText(str(port)) + self.update() class ServerWidget(QWidget, QtEventListener): - def __init__(self, network, parent=None): + def __init__(self, network: Network, parent=None): super().__init__(parent) self.network = network self.config = network.config - fixed_width_hostname = 24 * char_width_in_lineedit() - fixed_width_port = 6 * char_width_in_lineedit() - self.setLayout(QVBoxLayout()) - grid = QGridLayout(self) + grid = QGridLayout() msg = ' '.join([ _("Electrum connects to several nodes in order to download block headers and find out the longest blockchain."), _("This blockchain is used to verify the transactions sent by your transaction server.") ]) + self.status_label_header = QLabel(_('Status') + ':') self.status_label = QLabel('') - grid.addWidget(QLabel(_('Status') + ':'), 0, 0) + self.status_label_helpbutton = HelpButton(msg) + grid.addWidget(self.status_label_header, 0, 0) grid.addWidget(self.status_label, 0, 1, 1, 3) - grid.addWidget(HelpButton(msg), 0, 4) + grid.addWidget(self.status_label_helpbutton, 0, 4) self.autoconnect_cb = QCheckBox(_('Select server automatically')) - self.autoconnect_cb.setEnabled(self.config.cv.NETWORK_AUTO_CONNECT.is_modifiable()) + self.autoconnect_cb.stateChanged.connect(self.on_server_settings_changed) + msg = ' '.join([ _("If auto-connect is enabled, Electrum will always use a server that is on the longest blockchain."), _("If it is disabled, you have to choose a server you want to use. Electrum will warn you if your server is lagging.") @@ -604,17 +391,19 @@ class ServerWidget(QWidget, QtEventListener): grid.addWidget(HelpButton(msg), 1, 4) self.server_e = QLineEdit() - self.server_e.setFixedWidth(fixed_width_hostname + fixed_width_port) + self.server_e.editingFinished.connect(self.on_server_settings_changed) msg = _("Electrum sends your wallet addresses to a single server, in order to receive your transaction history.") grid.addWidget(QLabel(_('Server') + ':'), 2, 0) grid.addWidget(self.server_e, 2, 1, 1, 3) grid.addWidget(HelpButton(msg), 2, 4) - self.height_label = QLabel('') msg = _('This is the height of your local copy of the blockchain.') - grid.addWidget(QLabel(_('Blockchain') + ':'), 3, 0) + self.height_label_header = QLabel(_('Blockchain') + ':') + self.height_label = QLabel('') + self.height_label_helpbutton = HelpButton(msg) + grid.addWidget(self.height_label_header, 3, 0) grid.addWidget(self.height_label, 3, 1) - grid.addWidget(HelpButton(msg), 3, 4) + grid.addWidget(self.height_label_helpbutton, 3, 4) self.split_label = QLabel('') grid.addWidget(self.split_label, 4, 0, 1, 3) @@ -636,9 +425,62 @@ class ServerWidget(QWidget, QtEventListener): self.register_callbacks() self.destroyed.connect(lambda: self.unregister_callbacks()) + self.update_from_config() + self.update() + @qt_event_listener def on_event_network_updated(self): - self.nodes_list_widget.update() + self.nodes_list_widget.update() # NOTE: move event handling to widget itself? + self.update() + + def on_server_settings_changed(self): + if not self.network._was_started: + self.update() + return + auto_connect = self.autoconnect_cb.isChecked() + server = self.server_e.text().strip() + net_params = self.network.get_parameters() + if server != net_params.server or auto_connect != net_params.auto_connect: + self.set_server() + + def update(self): + auto_connect = self.autoconnect_cb.isChecked() + self.server_e.setEnabled(self.config.cv.NETWORK_SERVER.is_modifiable() and not auto_connect) + + for item in [ + self.status_label_header, self.status_label, self.status_label_helpbutton, + self.height_label_header, self.height_label, self.height_label_helpbutton]: + item.setVisible(self.network._was_started) + + msg = '' + if self.network._was_started: + # Network was started, so we don't run in initial setup wizard. + # behavior in this case is to apply changes immediately. + # Also, we show block height and potential chain tips + height_str = _('{} blocks').format(self.network.get_local_height()) + self.height_label.setText(height_str) + self.status_label.setText(self.network.get_status()) + chains = self.network.get_blockchains() + if len(chains) > 1: + chain = self.network.blockchain() + forkpoint = chain.get_max_forkpoint() + name = chain.get_name() + msg = _('Chain split detected at block {0}').format(forkpoint) + '\n' + if auto_connect: + msg += _('You are following branch {}').format(name) + else: + msg += _('Your server is on branch {0} ({1} blocks)').format(name, chain.get_branch_size()) + self.split_label.setText(msg) + + def update_from_config(self): + auto_connect = self.config.NETWORK_AUTO_CONNECT + self.autoconnect_cb.setChecked(auto_connect) + server = self.config.NETWORK_SERVER + self.server_e.setText(server) + + self.autoconnect_cb.setEnabled(self.config.cv.NETWORK_AUTO_CONNECT.is_modifiable()) + 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()) def follow_branch(self, chain_id): self.network.run_from_another_thread(self.network.follow_chain_given_id(chain_id)) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index 0df5ebfaf..7fc6833b1 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -25,7 +25,7 @@ from electrum.submarine_swaps import SwapServerError from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit from .paytoedit import InvalidPaymentIdentifier from .util import (WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit, - get_iconname_camera, read_QIcon, ColorScheme, icon_path, IconLabel) + get_iconname_camera, read_QIcon, ColorScheme, icon_path, IconLabel, Spinner) from .invoice_list import InvoiceList if TYPE_CHECKING: @@ -142,14 +142,8 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.paste_button.setToolTip(_('Paste invoice from clipboard')) self.paste_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self.spinner = QMovie(icon_path('spinner.gif')) - self.spinner.setScaledSize(QSize(24, 24)) - self.spinner.setBackgroundColor(QColor('black')) - self.spinner_l = QLabel() - self.spinner_l.setMargin(5) - self.spinner_l.setVisible(False) - self.spinner_l.setMovie(self.spinner) - grid.addWidget(self.spinner_l, 0, 1, 1, 4, Qt.AlignmentFlag.AlignRight) + self.spinner = Spinner() + grid.addWidget(self.spinner, 0, 1, 1, 4, Qt.AlignmentFlag.AlignRight) self.save_button = EnterButton(_("Save"), self.do_save_invoice) self.save_button.setEnabled(False) @@ -216,13 +210,6 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.setTabOrder(self.send_button, self.invoice_list) - def showSpinner(self, b): - self.spinner_l.setVisible(b) - if b: - self.spinner.start() - else: - self.spinner.stop() - def on_amount_changed(self, text): # FIXME: implement full valid amount check to enable/disable Pay button pi = self.payto_e.payment_identifier @@ -395,7 +382,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): def prepare_for_send_tab_network_lookup(self): for btn in [self.save_button, self.send_button, self.clear_button]: btn.setEnabled(False) - self.showSpinner(True) + self.spinner.setVisible(True) def payment_request_error(self, error): self.show_message(error) @@ -506,7 +493,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): # TODO: resolve can happen while typing, we don't want message dialogs to pop up # currently we don't set error for emaillike recipients to avoid just that self.logger.debug('payment identifier resolve done') - self.showSpinner(False) + self.spinner.setVisible(False) if pi.error: self.show_error(pi.error) self.do_clear() @@ -562,7 +549,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): return self.amount_e.get_amount() or 0 def on_finalize_done(self, pi: PaymentIdentifier): - self.showSpinner(False) + self.spinner.setVisible(False) self.update_fields() if pi.error: self.show_error(pi.error) diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index 1e9fc6fb5..273f73805 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -11,7 +11,7 @@ from typing import (NamedTuple, Callable, Optional, TYPE_CHECKING, List, Any, Se from PyQt6 import QtCore from PyQt6.QtGui import (QFont, QColor, QCursor, QPixmap, QImage, - QPalette, QIcon, QFontMetrics, QPainter, QContextMenuEvent) + QPalette, QIcon, QFontMetrics, QPainter, QContextMenuEvent, QMovie) from PyQt6.QtCore import (Qt, pyqtSignal, QCoreApplication, QThread, QSize, QRect, QPoint, QObject) from PyQt6.QtWidgets import (QPushButton, QLabel, QMessageBox, QHBoxLayout, QVBoxLayout, QLineEdit, QStyle, QDialog, QGroupBox, QButtonGroup, QRadioButton, @@ -117,6 +117,22 @@ class AmountLabel(QLabel): self.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) +class Spinner(QLabel): + def __init__(self, *args, **kwargs): + QLabel.__init__(self, *args, **kwargs) + self.spinner = QMovie(icon_path('spinner.gif')) + self.spinner.setScaledSize(QSize(20, 20)) + self.spinner.frameChanged.connect(lambda: self.setPixmap(self.spinner.currentPixmap())) + self.setVisible(False) + + def setVisible(self, visible): + if visible: + self.spinner.start() + else: + self.spinner.stop() + super().setVisible(visible) + + class HelpMixin: def __init__(self, help_text: str, *, help_title: str = None): assert isinstance(self, QWidget), "HelpMixin must be a QWidget instance!" diff --git a/electrum/gui/qt/wizard/server_connect.py b/electrum/gui/qt/wizard/server_connect.py index b4489d7e0..ff8412e6c 100644 --- a/electrum/gui/qt/wizard/server_connect.py +++ b/electrum/gui/qt/wizard/server_connect.py @@ -88,16 +88,15 @@ class WCWelcome(WizardComponent): class WCProxyConfig(WizardComponent): def __init__(self, parent, wizard): WizardComponent.__init__(self, parent, wizard, title=_('Proxy')) - self.pw = ProxyWidget(self) + self.pw = ProxyWidget(wizard._daemon.network, self) self.pw.proxy_cb.setChecked(True) self.pw.proxy_host.setText('localhost') self.pw.proxy_port.setText('9050') self.layout().addWidget(self.pw) - self.layout().addStretch(1) self._valid = True def apply(self): - self.wizard_data['proxy'] = self.pw.get_proxy_settings() + self.wizard_data['proxy'] = self.pw.get_proxy_settings().to_dict() class WCServerConfig(WizardComponent): diff --git a/electrum/gui/text.py b/electrum/gui/text.py index 277a4d897..9063e5181 100644 --- a/electrum/gui/text.py +++ b/electrum/gui/text.py @@ -23,7 +23,7 @@ from electrum.transaction import PartialTxOutput from electrum.wallet import Wallet, Abstract_Wallet from electrum.wallet_db import WalletDB from electrum.storage import WalletStorage -from electrum.network import NetworkParameters, TxBroadcastError, BestEffortRequestFailed +from electrum.network import NetworkParameters, TxBroadcastError, BestEffortRequestFailed, ProxySettings from electrum.interface import ServerAddr from electrum.invoices import Invoice @@ -753,10 +753,14 @@ class ElectrumGui(BaseElectrumGui, EventListener): self.show_message("Error:" + server_str + "\nIn doubt, type \"auto-connect\"") return False if out.get('server') or out.get('proxy') or out.get('proxy user') or out.get('proxy pass'): - new_proxy_config = electrum.network.deserialize_proxy(out.get('proxy')) if out.get('proxy') else proxy_config - if new_proxy_config: - new_proxy_config['user'] = out.get('proxy user') if 'proxy user' in out else proxy_config['user'] - new_proxy_config['pass'] = out.get('proxy pass') if 'proxy pass' in out else proxy_config['pass'] + if out.get('proxy'): + new_proxy_config = ProxySettings() + new_proxy_config.deserialize_proxy_cfgstr(out.get('proxy')) + new_proxy_config.user = out.get('proxy user', proxy_config.user) + new_proxy_config.password = out.get('proxy pass', proxy_config.password) + new_proxy_config.enabled = True + else: + new_proxy_config = proxy_config net_params = NetworkParameters( server=server_addr, proxy=new_proxy_config, diff --git a/electrum/network.py b/electrum/network.py index 5eaa8b1ea..1dec07f9b 100644 --- a/electrum/network.py +++ b/electrum/network.py @@ -31,7 +31,10 @@ import threading import socket import json import sys -from typing import NamedTuple, Optional, Sequence, List, Dict, Tuple, TYPE_CHECKING, Iterable, Set, Any, TypeVar +from typing import ( + NamedTuple, Optional, Sequence, List, Dict, Tuple, TYPE_CHECKING, Iterable, Set, Any, TypeVar, + Callable +) import traceback import concurrent from concurrent import futures @@ -48,7 +51,7 @@ from . import util from .util import (log_exceptions, ignore_exceptions, OldTaskGroup, bfh, make_aiohttp_session, send_exception_to_crash_reporter, is_hash256_str, is_non_negative_integer, MyEncoder, NetworkRetryManager, - error_text_str_to_safe_str) + error_text_str_to_safe_str, detect_tor_socks_proxy) from .bitcoin import COIN, DummyAddress, DummyAddressUsedInTxException from . import constants from . import blockchain @@ -158,65 +161,133 @@ def pick_random_server(hostmap=None, *, allowed_protocols: Iterable[str], return random.choice(eligible) if eligible else None -class NetworkParameters(NamedTuple): - server: ServerAddr - proxy: Optional[dict] - auto_connect: bool - oneserver: bool = False +def is_valid_port(ps: str): + try: + return 0 < int(ps) < 65535 + except ValueError: + return False -proxy_modes = ['socks4', 'socks5'] +def is_valid_host(ph: str): + try: + NetAddress(ph, '1') + except ValueError: + return False + return True -def serialize_proxy(p): - if not isinstance(p, dict): - return None - return ':'.join([p.get('mode'), p.get('host'), p.get('port')]) +class ProxySettings: + MODES = ['socks4', 'socks5'] + probe_fut = None -def deserialize_proxy(s: Optional[str], user: str = None, password: str = None) -> Optional[dict]: - if not isinstance(s, str): - return None - if s.lower() == 'none': - return None - proxy = {"mode": "socks5", "host": "localhost"} + def __init__(self): + self.enabled = False + self.mode = 'socks5' + self.host = '' + self.port = '' + self.user = None + self.password = None - args = s.split(':') - if args[0] in proxy_modes: - proxy['mode'] = args[0] - args = args[1:] + def set_defaults(self): + self.__init__() # call __init__ for default values - def is_valid_port(ps: str): - try: - return 0 < int(ps) < 65535 - except ValueError: - return False + def serialize_proxy_cfgstr(self): + return ':'.join([self.mode, self.host, self.port]) - def is_valid_host(ph: str): - try: - NetAddress(ph, '1') - except ValueError: - return False - return True + def deserialize_proxy_cfgstr(self, s: Optional[str], user: str = None, password: str = None) -> None: + if s is None or (isinstance(s, str) and s.lower() == 'none'): + self.set_defaults() + self.user = user + self.password = password + return - # detect migrate from old settings - if len(args) == 4 and is_valid_host(args[0]) and is_valid_port(args[1]): # host:port:user:pass, - proxy['host'] = args[0] - proxy['port'] = args[1] - proxy['user'] = args[2] - proxy['password'] = args[3] + if not isinstance(s, str): + raise ValueError('proxy config not a string') + + args = s.split(':') + if args[0] in ProxySettings.MODES: + self.mode = args[0] + args = args[1:] + + # detect migrate from old settings + if len(args) == 4 and is_valid_host(args[0]) and is_valid_port(args[1]): # host:port:user:pass, + self.host = args[0] + self.port = args[1] + self.user = args[2] + self.password = args[3] + else: + self.host = ':'.join(args[:-1]) + self.port = args[-1] + self.user = user + self.password = password + + if not is_valid_host(self.host) or not is_valid_port(self.port): + self.enabled = False + + def to_dict(self): + return { + 'enabled': self.enabled, + 'mode': self.mode, + 'host': self.host, + 'port': self.port, + 'user': self.user, + 'password': self.password + } + + @classmethod + def from_config(cls, config: 'SimpleConfig') -> 'ProxySettings': + proxy = ProxySettings() + proxy.deserialize_proxy_cfgstr( + config.NETWORK_PROXY, config.NETWORK_PROXY_USER, config.NETWORK_PROXY_PASSWORD + ) + proxy.enabled = config.NETWORK_PROXY_ENABLED return proxy - proxy['host'] = ':'.join(args[:-1]) - proxy['port'] = args[-1] + @classmethod + def from_dict(cls, d: dict) -> 'ProxySettings': + proxy = ProxySettings() + proxy.enabled = d.get('enabled', proxy.enabled) + proxy.mode = d.get('mode', proxy.mode) + proxy.host = d.get('host', proxy.host) + proxy.port = d.get('port', proxy.port) + proxy.user = d.get('user', proxy.user) + proxy.password = d.get('password', proxy.password) + return proxy - if not is_valid_host(proxy['host']) or not is_valid_port(proxy['port']): - return None + @classmethod + def probe_tor(cls, on_finished: Callable[[str | None, int | None], None]): + async def detect_task(finished: Callable[[str | None, int | None], None]): + net_addr = await detect_tor_socks_proxy() + if net_addr is None: + finished('', -1) + else: + host = net_addr[0] + port = net_addr[1] + finished(host, port) + cls.probe_fut = None - proxy['user'] = user - proxy['password'] = password + if cls.probe_fut: # one probe at a time + return + cls.probe_fut = asyncio.run_coroutine_threadsafe(detect_task(on_finished), util.get_asyncio_loop()) - return proxy + def __eq__(self, other): + return self.enabled == other.enabled \ + and self.mode == other.mode \ + and self.host == other.host \ + and self.port == other.port \ + and self.user == other.user \ + and self.password == other.password + + def __str__(self): + return f'{self.enabled=} {self.mode=} {self.host=} {self.port=} {self.user=}' + + +class NetworkParameters(NamedTuple): + server: ServerAddr + proxy: ProxySettings + auto_connect: bool + oneserver: bool = False class BestEffortRequestFailed(NetworkException): pass @@ -325,7 +396,7 @@ class Network(Logger, NetworkRetryManager[ServerAddr]): self._allowed_protocols = {PREFERRED_NETWORK_PROTOCOL} - self.proxy = None # type: Optional[dict] + self.proxy = ProxySettings() self.is_proxy_tor = None self._init_parameters_from_config() @@ -535,8 +606,7 @@ class Network(Logger, NetworkRetryManager[ServerAddr]): dns_hacks.configure_dns_resolver() self.auto_connect = self.config.NETWORK_AUTO_CONNECT self._set_default_server() - self._set_proxy(deserialize_proxy(self.config.NETWORK_PROXY, self.config.NETWORK_PROXY_USER, - self.config.NETWORK_PROXY_PASSWORD)) + self._set_proxy(ProxySettings.from_config(self.config)) self._maybe_set_oneserver() def get_donation_address(self): @@ -659,7 +729,7 @@ class Network(Logger, NetworkRetryManager[ServerAddr]): 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}" - def _set_proxy(self, proxy: Optional[dict]): + def _set_proxy(self, proxy: ProxySettings): if self.proxy == proxy: return @@ -673,9 +743,9 @@ class Network(Logger, NetworkRetryManager[ServerAddr]): util.trigger_callback('proxy_set', self.proxy) def _detect_if_proxy_is_tor(self) -> None: - def tor_probe_task(p): + async def tor_probe_task(p): assert p is not None - is_tor = util.is_tor_socks_port(p['host'], int(p['port'])) + is_tor = await util.is_tor_socks_port(p.host, int(p.port)) if self.proxy == p: # is this the proxy we probed? if self.is_proxy_tor != is_tor: self.logger.info(f'Proxy is {"" if is_tor else "not "}TOR') @@ -683,32 +753,38 @@ class Network(Logger, NetworkRetryManager[ServerAddr]): util.trigger_callback('tor_probed', is_tor) proxy = self.proxy - if proxy and proxy['mode'] == 'socks5': - t = threading.Thread(target=tor_probe_task, args=(proxy,), daemon=True) - t.start() + if proxy and proxy.enabled and proxy.mode == 'socks5': + # FIXME GC issues? do we need to store the Future? + asyncio.run_coroutine_threadsafe(tor_probe_task(proxy), self.asyncio_loop) @log_exceptions async def set_parameters(self, net_params: NetworkParameters): proxy = net_params.proxy - proxy_str = serialize_proxy(proxy) - proxy_user = proxy['user'] if proxy else None - proxy_pass = proxy['password'] if proxy else None + proxy_str = proxy.serialize_proxy_cfgstr() + proxy_enabled = proxy.enabled + proxy_user = proxy.user + proxy_pass = proxy.password server = net_params.server # sanitize parameters try: if proxy: - proxy_modes.index(proxy['mode']) + 1 - int(proxy['port']) + # proxy_modes.index(proxy['mode']) + 1 + ProxySettings.MODES.index(proxy.mode) + 1 + # int(proxy['port']) + int(proxy.port) except Exception: - return + proxy.enabled = False + # return self.config.NETWORK_AUTO_CONNECT = net_params.auto_connect self.config.NETWORK_ONESERVER = net_params.oneserver + self.config.NETWORK_PROXY_ENABLED = proxy_enabled 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) # abort if changes were not allowed by config if self.config.NETWORK_SERVER != str(server) \ + or self.config.NETWORK_PROXY_ENABLED != proxy_enabled \ or self.config.NETWORK_PROXY != proxy_str \ or self.config.NETWORK_PROXY_USER != proxy_user \ or self.config.NETWORK_PROXY_PASSWORD != proxy_pass \ @@ -885,7 +961,7 @@ class Network(Logger, NetworkRetryManager[ServerAddr]): return self.config.NETWORK_TIMEOUT if self.oneserver and not self.auto_connect: return request_type.MOST_RELAXED - if self.proxy: + if self.proxy and self.proxy.enabled: return request_type.RELAXED return request_type.NORMAL diff --git a/electrum/simple_config.py b/electrum/simple_config.py index 6287c46b9..ad2036715 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -566,6 +566,7 @@ class SimpleConfig(Logger): NETWORK_PROXY = ConfigVar('proxy', default=None, type_=str, convert_getter=lambda v: "none" if v is None else v) 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) NETWORK_NOONION = ConfigVar('noonion', default=False, type_=bool) NETWORK_OFFLINE = ConfigVar('offline', default=False, type_=bool) diff --git a/electrum/util.py b/electrum/util.py index a5237ee68..66188fae6 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -67,7 +67,7 @@ from .i18n import _ from .logging import get_logger, Logger if TYPE_CHECKING: - from .network import Network + from .network import Network, ProxySettings from .interface import Interface from .simple_config import SimpleConfig from .paymentrequest import PaymentRequest @@ -1313,7 +1313,7 @@ def format_short_id(short_channel_id: Optional[bytes]): + 'x' + str(int.from_bytes(short_channel_id[6:], 'big')) -def make_aiohttp_session(proxy: Optional[dict], headers=None, timeout=None): +def make_aiohttp_session(proxy: Optional['ProxySettings'], headers=None, timeout=None): if headers is None: headers = {'User-Agent': 'Electrum'} if timeout is None: @@ -1324,13 +1324,13 @@ def make_aiohttp_session(proxy: Optional[dict], headers=None, timeout=None): timeout = aiohttp.ClientTimeout(total=timeout) ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=ca_path) - if proxy: + if proxy and proxy.enabled: connector = ProxyConnector( - proxy_type=ProxyType.SOCKS5 if proxy['mode'] == 'socks5' else ProxyType.SOCKS4, - host=proxy['host'], - port=int(proxy['port']), - username=proxy.get('user', None), - password=proxy.get('password', None), + proxy_type=ProxyType.SOCKS5 if proxy.mode == 'socks5' else ProxyType.SOCKS4, + host=proxy.host, + port=int(proxy.port), + username=proxy.user, + password=proxy.password, rdns=True, # needed to prevent DNS leaks over proxy ssl=ssl_context, ) @@ -1547,32 +1547,51 @@ class NetworkJobOnDefaultServer(Logger, ABC): return s -def detect_tor_socks_proxy() -> Optional[Tuple[str, int]]: +async def detect_tor_socks_proxy() -> Optional[Tuple[str, int]]: # Probable ports for Tor to listen at candidates = [ ("127.0.0.1", 9050), + ("127.0.0.1", 9051), ("127.0.0.1", 9150), ] - for net_addr in candidates: - if is_tor_socks_port(*net_addr): - return net_addr - return None + + proxy_addr = None + async def test_net_addr(net_addr): + is_tor = await is_tor_socks_port(*net_addr) + # set result, and cancel remaining probes + if is_tor: + nonlocal proxy_addr + proxy_addr = net_addr + await group.cancel_remaining() + + async with OldTaskGroup() as group: + for net_addr in candidates: + await group.spawn(test_net_addr(net_addr)) + return proxy_addr -def is_tor_socks_port(host: str, port: int) -> bool: +@log_exceptions +async def is_tor_socks_port(host: str, port: int) -> bool: + # mimic "tor-resolve 0.0.0.0". + # see https://github.com/spesmilo/electrum/issues/7317#issuecomment-1369281075 + # > this is a socks5 handshake, followed by a socks RESOLVE request as defined in + # > [tor's socks extension spec](https://github.com/torproject/torspec/blob/7116c9cdaba248aae07a3f1d0e15d9dd102f62c5/socks-extensions.txt#L63), + # > resolving 0.0.0.0, which being an IP, tor resolves itself without needing to ask a relay. + writer = None try: - with socket.create_connection((host, port), timeout=10) as s: - # mimic "tor-resolve 0.0.0.0". - # see https://github.com/spesmilo/electrum/issues/7317#issuecomment-1369281075 - # > this is a socks5 handshake, followed by a socks RESOLVE request as defined in - # > [tor's socks extension spec](https://github.com/torproject/torspec/blob/7116c9cdaba248aae07a3f1d0e15d9dd102f62c5/socks-extensions.txt#L63), - # > resolving 0.0.0.0, which being an IP, tor resolves itself without needing to ask a relay. - s.send(b'\x05\x01\x00\x05\xf0\x00\x03\x070.0.0.0\x00\x00') - if s.recv(1024) == b'\x05\x00\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00': + async with async_timeout(10): + reader, writer = await asyncio.open_connection(host, port) + writer.write(b'\x05\x01\x00\x05\xf0\x00\x03\x070.0.0.0\x00\x00') + await writer.drain() + data = await reader.read(1024) + if data == b'\x05\x00\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00': return True - except socket.error: - pass - return False + return False + except (OSError, asyncio.TimeoutError): + return False + finally: + if writer: + writer.close() AS_LIB_USER_I_WANT_TO_MANAGE_MY_OWN_ASYNCIO_LOOP = False # used by unit tests @@ -1969,10 +1988,10 @@ class ESocksProxy(aiorpcx.SOCKSProxy): @classmethod def from_network_settings(cls, network: Optional['Network']) -> Optional['ESocksProxy']: - if not network or not network.proxy: + if not network or not network.proxy or not network.proxy.enabled: return None proxy = network.proxy - username, pw = proxy.get('user'), proxy.get('password') + username, pw = proxy.user, proxy.password if not username or not pw: # is_proxy_tor is tri-state; None indicates it is still probing the proxy to test for TOR if network.is_proxy_tor: @@ -1981,10 +2000,10 @@ class ESocksProxy(aiorpcx.SOCKSProxy): auth = None else: auth = aiorpcx.socks.SOCKSUserAuth(username, pw) - addr = aiorpcx.NetAddress(proxy['host'], proxy['port']) - if proxy['mode'] == "socks4": + addr = aiorpcx.NetAddress(proxy.host, proxy.port) + if proxy.mode == "socks4": ret = cls(addr, aiorpcx.socks.SOCKS4a, auth) - elif proxy['mode'] == "socks5": + elif proxy.mode == "socks5": ret = cls(addr, aiorpcx.socks.SOCKS5, auth) else: raise NotImplementedError # http proxy not available with aiorpcx diff --git a/electrum/wizard.py b/electrum/wizard.py index 0b34463e0..9df66ce61 100644 --- a/electrum/wizard.py +++ b/electrum/wizard.py @@ -7,6 +7,7 @@ from electrum.i18n import _ from electrum.interface import ServerAddr from electrum.keystore import hardware_keystore from electrum.logging import get_logger +from electrum.network import ProxySettings from electrum.plugin import run_hook from electrum.slip39 import EncryptedSeed from electrum.storage import WalletStorage, StorageEncryptionVersion @@ -731,9 +732,8 @@ class ServerConnectWizard(AbstractWizard): return self._logger.debug(f'configuring proxy: {proxy_settings!r}') net_params = self._daemon.network.get_parameters() - if not proxy_settings['enabled']: - proxy_settings = None - net_params = net_params._replace(proxy=proxy_settings, auto_connect=bool(wizard_data['autoconnect'])) + proxy = ProxySettings.from_dict(proxy_settings) + net_params = net_params._replace(proxy=proxy, auto_connect=bool(wizard_data['autoconnect'])) self._daemon.network.run_from_another_thread(self._daemon.network.set_parameters(net_params)) def do_configure_server(self, wizard_data: dict): diff --git a/tests/test_wizard.py b/tests/test_wizard.py index 8e98b9c76..822c22098 100644 --- a/tests/test_wizard.py +++ b/tests/test_wizard.py @@ -2,7 +2,7 @@ import os from electrum import SimpleConfig from electrum.interface import ServerAddr -from electrum.network import NetworkParameters +from electrum.network import NetworkParameters, ProxySettings from electrum.plugin import Plugins from electrum.wizard import ServerConnectWizard, NewWalletWizard from tests import ElectrumTestCase @@ -88,7 +88,7 @@ class ServerConnectWizardTestCase(WizardTestCase): self.assertTrue(w.is_last_view(v.view, d)) self.assertTrue(w._daemon.network.run_called) - self.assertEqual(NetworkParameters(server=None, proxy=d_proxy, auto_connect=True, oneserver=None), w._daemon.network.parameters) + self.assertEqual(NetworkParameters(server=None, 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))