diff --git a/cli/onionshare_cli/censorship.py b/cli/onionshare_cli/censorship.py index 9f41d61c..d9b02616 100644 --- a/cli/onionshare_cli/censorship.py +++ b/cli/onionshare_cli/censorship.py @@ -198,3 +198,65 @@ class CensorshipCircumvention(object): return False return result + + def save_settings(self, settings, bridge_settings): + """ + Checks the bridges and saves them in settings. + """ + bridges_ok = False + self.settings = settings + + # @TODO there might be several bridge types recommended. + # Should we attempt to iterate over each type if one of them fails to connect? + # But if so, how to stop it starting 3 separate Tor connection threads? + # for bridges in request_bridges["settings"]: + bridges = bridge_settings["settings"][0]["bridges"] + self.common.log( + "CensorshipCircumvention", + "save_settings", + f"Obtained bridges: {bridges}", + ) + bridge_strings = bridges["bridge_strings"] + bridge_type = bridges["type"] + bridge_source = bridges["source"] + + # If the recommended bridge source is to use the built-in + # bridges, set that in our settings, as if the user had + # selected the built-in bridges for a specific PT themselves. + # + if bridge_source == "builtin": + self.settings.set("bridges_type", "built-in") + if bridge_type == "obfs4": + self.settings.set("bridges_builtin_pt", "obfs4") + if bridge_type == "snowflake": + self.settings.set("bridges_builtin_pt", "snowflake") + if bridge_type == "meek": + self.settings.set("bridges_builtin_pt", "meek-azure") + bridges_ok = True + else: + # Any other type of bridge we can treat as custom. + self.settings.set("bridges_type", "custom") + + # Sanity check the bridges provided from the Tor API before saving + bridges_checked = self.common.check_bridges_valid(bridge_strings) + + if bridges_checked: + self.settings.set("bridges_custom", "\n".join(bridges_checked)) + bridges_ok = True + + # If we got any good bridges, save them to settings and return. + if bridges_ok: + self.common.log( + "CensorshipCircumvention", + "save_settings", + "Saving settings with automatically-obtained bridges", + ) + self.settings.save() + return True + else: + self.common.log( + "CensorshipCircumvention", + "save_settings", + "Could not use any of the obtained bridges.", + ) + return False diff --git a/cli/onionshare_cli/common.py b/cli/onionshare_cli/common.py index a8e32411..4b483cf5 100644 --- a/cli/onionshare_cli/common.py +++ b/cli/onionshare_cli/common.py @@ -28,6 +28,7 @@ import sys import threading import time import shutil +import re from pkg_resources import resource_filename import colorama @@ -441,6 +442,40 @@ class Common: r = random.SystemRandom() return "-".join(r.choice(wordlist) for _ in range(word_count)) + def check_bridges_valid(self, bridges): + """ + Does a regex check against a supplied list of bridges, to make sure they + are valid strings depending on the bridge type. + """ + valid_bridges = [] + self.log("Common", "check_bridges_valid", "Checking bridge syntax") + for bridge in bridges: + if bridge != "": + # Check the syntax of the custom bridge to make sure it looks legitimate + ipv4_pattern = re.compile( + "(obfs4\s+)?(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]):([0-9]+)(\s+)([A-Z0-9]+)(.+)$" + ) + ipv6_pattern = re.compile( + "(obfs4\s+)?\[(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\]:[0-9]+\s+[A-Z0-9]+(.+)$" + ) + meek_lite_pattern = re.compile( + "(meek_lite)(\s)+([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+)(\s)+([0-9A-Z]+)(\s)+url=(.+)(\s)+front=(.+)" + ) + snowflake_pattern = re.compile( + "(snowflake)(\s)+([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+)(\s)+([0-9A-Z]+)" + ) + if ( + ipv4_pattern.match(bridge) + or ipv6_pattern.match(bridge) + or meek_lite_pattern.match(bridge) + or snowflake_pattern.match(bridge) + ): + valid_bridges.append(bridge) + if valid_bridges: + return valid_bridges + else: + return False + def is_flatpak(self): """ Returns True if OnionShare is running in a Flatpak sandbox @@ -453,6 +488,7 @@ class Common: """ return os.environ.get("SNAP_INSTANCE_NAME") == "onionshare" + @staticmethod def random_string(num_bytes, output_len=None): """ diff --git a/cli/onionshare_cli/settings.py b/cli/onionshare_cli/settings.py index 8a4a9939..241ed083 100644 --- a/cli/onionshare_cli/settings.py +++ b/cli/onionshare_cli/settings.py @@ -114,6 +114,7 @@ class Settings(object): "persistent_tabs": [], "locale": None, # this gets defined in fill_in_defaults() "theme": 0, + "censorship_circumvention": False, } self._settings = {} self.fill_in_defaults() diff --git a/cli/tests/test_cli_settings.py b/cli/tests/test_cli_settings.py index f370a674..9f255e8e 100644 --- a/cli/tests/test_cli_settings.py +++ b/cli/tests/test_cli_settings.py @@ -37,6 +37,7 @@ class TestSettings: "bridges_builtin": {}, "persistent_tabs": [], "theme": 0, + "censorship_circumvention": False, } for key in settings_obj._settings: # Skip locale, it will not always default to the same thing diff --git a/desktop/src/onionshare/main_window.py b/desktop/src/onionshare/main_window.py index 546592a1..79738d38 100644 --- a/desktop/src/onionshare/main_window.py +++ b/desktop/src/onionshare/main_window.py @@ -30,6 +30,7 @@ from .tab_widget import TabWidget from .gui_common import GuiCommon from .threads import OnionCleanupThread +from onionshare_cli.meek import Meek class MainWindow(QtWidgets.QMainWindow): """ @@ -160,8 +161,11 @@ class MainWindow(QtWidgets.QMainWindow): self.setCentralWidget(central_widget) self.show() + # Instantiate Meek, which the TorConnectionDialog may use to resolve + # connection issues by automatically obtaining bridges. + self.meek = Meek(self.common, get_tor_paths=self.common.gui.get_tor_paths) # Start the "Connecting to Tor" dialog, which calls onion.connect() - tor_con = TorConnectionDialog(self.common) + tor_con = TorConnectionDialog(self.common, self.meek) tor_con.canceled.connect(self.tor_connection_canceled) tor_con.open_tor_settings.connect(self.tor_connection_open_tor_settings) if not self.common.gui.local_only: diff --git a/desktop/src/onionshare/resources/locale/en.json b/desktop/src/onionshare/resources/locale/en.json index f8c4cd2b..85b7e483 100644 --- a/desktop/src/onionshare/resources/locale/en.json +++ b/desktop/src/onionshare/resources/locale/en.json @@ -64,6 +64,7 @@ "gui_settings_tor_bridges": "Connect using a Tor bridge?", "gui_settings_tor_bridges_label": "Bridges help you access the Tor Network in places where Tor is blocked. Depending on where you are, one bridge may work better than another.", "gui_settings_bridge_use_checkbox": "Use a bridge", + "gui_settings_censorship_circumvention_checkbox": "Attempt to automatically find a bridge based on your country if Tor fails to connect", "gui_settings_bridge_radio_builtin": "Select a built-in bridge", "gui_settings_bridge_none_radio_option": "Don't use a bridge", "gui_settings_meek_lite_expensive_warning": "Warning: The meek-azure bridges are very costly for the Tor Project to run.

Only use them if unable to connect to Tor directly, via obfs4 transports, or other normal bridges.", diff --git a/desktop/src/onionshare/tor_connection.py b/desktop/src/onionshare/tor_connection.py index 2cc599c4..5c427d3d 100644 --- a/desktop/src/onionshare/tor_connection.py +++ b/desktop/src/onionshare/tor_connection.py @@ -41,6 +41,7 @@ from onionshare_cli.onion import ( from . import strings from .gui_common import GuiCommon from .widgets import Alert +from onionshare_cli.censorship import CensorshipCircumvention class TorConnectionDialog(QtWidgets.QProgressDialog): @@ -52,13 +53,15 @@ class TorConnectionDialog(QtWidgets.QProgressDialog): success = QtCore.Signal() def __init__( - self, common, custom_settings=False, testing_settings=False, onion=None + self, common, meek, custom_settings=False, testing_settings=False, onion=None ): super(TorConnectionDialog, self).__init__(None) self.common = common self.testing_settings = testing_settings + self.meek = meek + if custom_settings: self.settings = custom_settings else: @@ -137,6 +140,30 @@ class TorConnectionDialog(QtWidgets.QProgressDialog): def alert(): Alert(self.common, msg, QtWidgets.QMessageBox.Warning, title=self.title) + # If we are allowed to try automatically resolving connection issues + # (e.g possible censorship) by obtaining bridges for the user, do so + elif self.settings.get("censorship_circumvention"): + def alert(): + return + + # Automatically try to obtain bridges from the Censorship Circumvention API + self.common.log( + "TorConnectionDialog", + "_error_connecting_to_tor", + "Trying to automatically obtain bridges", + ) + self.meek.start() + self.censorship_circumvention = CensorshipCircumvention( + self.common, self.meek + ) + bridge_settings = self.censorship_circumvention.request_settings( + country="tm" + ) + self.meek.cleanup() + + if bridge_settings and self.censorship_circumvention.save_settings(self.settings, bridge_settings): + # Try and connect again + self.start() else: # If not testing, open settings after displaying the error def alert(): @@ -165,7 +192,7 @@ class TorConnectionWidget(QtWidgets.QWidget): success = QtCore.Signal() fail = QtCore.Signal(str) - def __init__(self, common, status_bar): + def __init__(self, common, status_bar, meek): super(TorConnectionWidget, self).__init__(None) self.common = common self.common.log("TorConnectionWidget", "__init__") @@ -181,6 +208,8 @@ class TorConnectionWidget(QtWidgets.QWidget): ) self.cancel_button.clicked.connect(self.cancel_clicked) + self.meek = meek + progress_layout = QtWidgets.QHBoxLayout() progress_layout.addWidget(self.progress) progress_layout.addWidget(self.cancel_button) @@ -263,7 +292,32 @@ class TorConnectionWidget(QtWidgets.QWidget): def _error_connecting_to_tor(self, msg): self.common.log("TorConnectionWidget", "_error_connecting_to_tor") self.active = False - self.fail.emit(msg) + + # If we are allowed to try automatically resolving connection issues + # (e.g possible censorship) by obtaining bridges for the user, do so + if self.settings.get("censorship_circumvention"): + # Automatically try to obtain bridges from the Censorship Circumvention API + self.common.log( + "TorConnectionWidget", + "_error_connecting_to_tor", + "Trying to automatically obtain bridges", + ) + self.meek.start() + self.censorship_circumvention = CensorshipCircumvention( + self.common, self.meek + ) + bridge_settings = self.censorship_circumvention.request_settings( + country="tm" + ) + self.meek.cleanup() + + if bridge_settings and self.censorship_circumvention.save_settings(self.settings, bridge_settings): + # Try and connect again + self.start() + else: + self.fail.emit() + else: + self.fail.emit() class TorConnectionThread(QtCore.QThread): diff --git a/desktop/src/onionshare/tor_settings_tab.py b/desktop/src/onionshare/tor_settings_tab.py index e28e5260..9d9ec2ef 100644 --- a/desktop/src/onionshare/tor_settings_tab.py +++ b/desktop/src/onionshare/tor_settings_tab.py @@ -21,7 +21,6 @@ along with this program. If not, see . from PySide2 import QtCore, QtWidgets, QtGui import sys import platform -import re import os from onionshare_cli.meek import Meek @@ -91,6 +90,12 @@ class TorSettingsTab(QtWidgets.QWidget): self.bridge_use_checkbox.stateChanged.connect( self.bridge_use_checkbox_state_changed ) + self.censorship_circumvention_checkbox = QtWidgets.QCheckBox( + strings._("gui_settings_censorship_circumvention_checkbox") + ) + self.censorship_circumvention_checkbox.stateChanged.connect( + self.censorship_circumvention_checkbox_state_changed + ) # Built-in bridge self.bridge_builtin_radio = QtWidgets.QRadioButton( @@ -164,6 +169,7 @@ class TorSettingsTab(QtWidgets.QWidget): bridges_layout = QtWidgets.QVBoxLayout() bridges_layout.addWidget(bridges_label) bridges_layout.addWidget(self.bridge_use_checkbox) + bridges_layout.addWidget(self.censorship_circumvention_checkbox) bridges_layout.addWidget(self.bridge_settings) self.bridges = QtWidgets.QWidget() @@ -330,7 +336,7 @@ class TorSettingsTab(QtWidgets.QWidget): columns_wrapper.setLayout(columns_layout) # Tor connection widget - self.tor_con = TorConnectionWidget(self.common, self.status_bar) + self.tor_con = TorConnectionWidget(self.common, self.status_bar, self.meek) self.tor_con.success.connect(self.tor_con_success) self.tor_con.fail.connect(self.tor_con_fail) self.tor_con.hide() @@ -430,6 +436,7 @@ class TorSettingsTab(QtWidgets.QWidget): if self.old_settings.get("bridges_enabled"): self.bridge_use_checkbox.setCheckState(QtCore.Qt.Checked) + self.censorship_circumvention_checkbox.setCheckState(QtCore.Qt.Checked) self.bridge_settings.show() bridges_type = self.old_settings.get("bridges_type") @@ -506,6 +513,16 @@ class TorSettingsTab(QtWidgets.QWidget): else: self.bridge_settings.hide() + def censorship_circumvention_checkbox_state_changed(self, state): + """ + 'Allow censorship circumvention (automatic bridges)' checkbox changed + """ + # Turning on censorship circumvention through the act of + # automatic bridge selection, implicitly means enabling + # bridges. + if state == QtCore.Qt.Checked: + self.bridge_use_checkbox.setCheckState(QtCore.Qt.Checked) + def bridge_builtin_radio_toggled(self, checked): """ 'Select a built-in bridge' radio button toggled @@ -812,6 +829,9 @@ class TorSettingsTab(QtWidgets.QWidget): if self.bridge_use_checkbox.checkState() == QtCore.Qt.Checked: settings.set("bridges_enabled", True) + if self.censorship_circumvention_checkbox.checkState() == QtCore.Qt.Checked: + settings.set("censorship_circumvention", True) + if self.bridge_builtin_radio.isChecked(): settings.set("bridges_type", "built-in") @@ -835,35 +855,10 @@ class TorSettingsTab(QtWidgets.QWidget): if self.bridge_custom_radio.isChecked(): settings.set("bridges_type", "custom") - new_bridges = [] bridges = self.bridge_custom_textbox.toPlainText().split("\n") - bridges_valid = False - for bridge in bridges: - if bridge != "": - # Check the syntax of the custom bridge to make sure it looks legitimate - ipv4_pattern = re.compile( - "(obfs4\s+)?(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]):([0-9]+)(\s+)([A-Z0-9]+)(.+)$" - ) - ipv6_pattern = re.compile( - "(obfs4\s+)?\[(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\]:[0-9]+\s+[A-Z0-9]+(.+)$" - ) - meek_lite_pattern = re.compile( - "(meek_lite)(\s)+([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+)(\s)+([0-9A-Z]+)(\s)+url=(.+)(\s)+front=(.+)" - ) - snowflake_pattern = re.compile( - "(snowflake)(\s)+([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+)(\s)+([0-9A-Z]+)" - ) - if ( - ipv4_pattern.match(bridge) - or ipv6_pattern.match(bridge) - or meek_lite_pattern.match(bridge) - or snowflake_pattern.match(bridge) - ): - new_bridges.append(bridge) - bridges_valid = True - + bridges_valid = self.common.check_bridges_valid(bridges) if bridges_valid: - new_bridges = "\n".join(new_bridges) + "\n" + new_bridges = "\n".join(bridges_valid) + "\n" settings.set("bridges_custom", new_bridges) else: self.error_label.setText(