From ff58f6c0ff5ef6dc10f02faf270f97d58adc5c4f Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Wed, 30 Aug 2023 15:58:41 +0800 Subject: [PATCH] Add Tor Routing support Signed-off-by: Loren Eteval --- .gitignore | 3 + Furious/Action/Connect.py | 127 ++++++++++++++++- Furious/Action/Routing.py | 4 + Furious/Action/Settings.py | 22 ++- Furious/Core/Core.py | 2 +- Furious/Core/TorRelay.py | 150 ++++++++++++++++++++ Furious/Utility/Constants.py | 7 + Furious/Utility/Settings.py | 2 + Furious/Utility/Translator.py | 85 +++++++++-- Furious/Utility/Utility.py | 84 +++++++++++ Furious/Widget/Application.py | 9 +- Furious/Widget/EditConfiguration.py | 29 ++-- Furious/Widget/EditRouting.py | 26 +++- Furious/Widget/TorRelaySettings.py | 210 ++++++++++++++++++++++++++++ Furious/Widget/Widget.py | 20 +++ 15 files changed, 743 insertions(+), 37 deletions(-) create mode 100644 Furious/Core/TorRelay.py create mode 100644 Furious/Widget/TorRelaySettings.py diff --git a/.gitignore b/.gitignore index 011b6ca..83e0e67 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,6 @@ cython_debug/ .idea/ .DS_Store + +# Tor data +Furious/Data/tordata diff --git a/Furious/Action/Connect.py b/Furious/Action/Connect.py index befaadf..cd31ae0 100644 --- a/Furious/Action/Connect.py +++ b/Furious/Action/Connect.py @@ -1,4 +1,5 @@ from Furious.Core.Core import XrayCore, Hysteria +from Furious.Core.TorRelay import TorRelay from Furious.Core.Intellisense import Intellisense from Furious.Core.Configuration import Configuration from Furious.Action.Routing import BUILTIN_ROUTING_TABLE, BUILTIN_ROUTING @@ -16,6 +17,7 @@ from Furious.Utility.Translator import gettext as _ from Furious.Utility.Proxy import Proxy from PySide6 import QtCore +from PySide6.QtTest import QTest from PySide6.QtNetwork import ( QNetworkAccessManager, QNetworkReply, @@ -82,6 +84,7 @@ class ConnectAction(Action): self.XrayCore = XrayCore() self.Hysteria = Hysteria() + self.TorRelay = TorRelay() def XrayCoreExitCallback(self, exitcode): if self.coreName: @@ -181,6 +184,8 @@ class ConnectAction(Action): self.XrayCore.stop() self.Hysteria.stop() + self.TorRelay.stop() + self.coreRunning = False def showConnectingProgressBar(self): @@ -331,6 +336,11 @@ class ConnectAction(Action): # Show the MessageBox and wait for user to close it self.httpProxyConfErrorBox.exec() + @property + def torRelayStorageObj(self): + # Handy reference + return APP().torRelaySettingsWidget.StorageObj + def configureCore(self): def validateProxyServer(server): # Validate proxy server @@ -387,8 +397,6 @@ class ConnectAction(Action): self.errorHttpProxyConf() else: if validateProxyServer(proxyServer): - self.coreRunning = True - routing = APP().Routing logger.info(f'core {XrayCore.name()} configured') @@ -419,9 +427,13 @@ class ConnectAction(Action): fixLoggingRelativePath('access') fixLoggingRelativePath('error') - # Filter Custom + # Filter Route My Traffic Through Tor, Custom if routing in list( - filter(lambda x: x != 'Custom', BUILTIN_ROUTING) + filter( + lambda x: x != 'Route My Traffic Through Tor' + and x != 'Custom', + BUILTIN_ROUTING, + ) ): routingObject = BUILTIN_ROUTING_TABLE[routing][XrayCore.name()] @@ -429,6 +441,29 @@ class ConnectAction(Action): logger.info(f'RoutingObject: {routingObject}') self.coreJSON['routing'] = routingObject + elif routing == 'Route My Traffic Through Tor': + logger.info(f'routing is {routing}') + + if TorRelay.checkIfExists(): + logger.info( + f'find Tor CLI in path success. Version: {TorRelay.version()}' + ) + + routingObject = {} + + logger.info(f'RoutingObject: {routingObject}') + + self.coreJSON['routing'] = routingObject + else: + logger.error('find Tor CLI in path failed') + + self.coreRunning = False + + self.disconnectReason = ( + f'{XrayCore.name()}: {_("Cannot find Tor CLI in PATH")}' + ) + + return XrayCore.name() elif routing == 'Custom': logger.info(f'routing is {routing}') logger.info(f'RoutingObject: {self.XrayRouting}') @@ -455,6 +490,8 @@ class ConnectAction(Action): # Fast fail self.coreJSON = {} + self.coreRunning = True + # Refresh configuration modified before. User cannot feel self.coreText = ujson.dumps( self.coreJSON, ensure_ascii=False, escape_forward_slashes=False @@ -462,6 +499,9 @@ class ConnectAction(Action): # Start core self.XrayCore.start(self.coreText) + if routing == 'Route My Traffic Through Tor': + self.startTorRelay(XrayCore.name(), proxyServer) + return XrayCore.name() if Intellisense.getCoreType(self.coreJSON) == Hysteria.name(): @@ -483,10 +523,13 @@ class ConnectAction(Action): logger.info(f'core {Hysteria.name()} configured') - # Filter Global, Custom + # Filter Route My Traffic Through Tor, Global, Custom if routing in list( filter( - lambda x: x != 'Global' and x != 'Custom', BUILTIN_ROUTING + lambda x: x != 'Route My Traffic Through Tor' + and x != 'Global' + and x != 'Custom', + BUILTIN_ROUTING, ) ): logger.info(f'routing is {routing}') @@ -498,6 +541,27 @@ class ConnectAction(Action): Hysteria.rule(routingObject.get('acl')), Hysteria.mmdb(routingObject.get('mmdb')), ) + elif routing == 'Route My Traffic Through Tor': + logger.info(f'routing is {routing}') + + if TorRelay.checkIfExists(): + logger.info( + f'find Tor CLI in path success. Version: {TorRelay.version()}' + ) + + self.Hysteria.start(self.coreText, '', '') + + self.startTorRelay(Hysteria.name(), proxyServer) + else: + logger.error('find Tor CLI in path failed') + + self.coreRunning = False + + self.disconnectReason = ( + f'{Hysteria.name()}: {_("Cannot find Tor CLI in PATH")}' + ) + + return Hysteria.name() elif routing == 'Global': logger.info(f'routing is {routing}') @@ -539,6 +603,57 @@ class ConnectAction(Action): # No matching core return '' + def startTorRelay(self, core, proxyServer, startCounter=0, step=1): + # Redirect Proxy + self.proxyServer = ( + f'127.0.0.1:{self.torRelayStorageObj.get("httpsTunnelPort", 9049)}' + ) + + try: + timeout = 1000 * int( + self.torRelayStorageObj.get('relayEstablishTimeout', 15) + ) + except Exception: + # Any non-exit exceptions + + # 15s + timeout = 1000 * 15 + + logger.info(f'{TorRelay.name()} bootstrap timeout is {timeout // 1000}s') + + # Start Tor Relay + if self.torRelayStorageObj.get('useProxy', True): + # Use proxy + self.TorRelay.start(proxyServer=proxyServer) + else: + # Do not use proxy + self.TorRelay.start() + + while ( + self.coreRunning + and startCounter < timeout + and self.TorRelay.bootstrapPercentage != 100 + ): + QTest.qWait(step) + + startCounter += step + + if not self.coreRunning: + # Interrupted externally. Return + return + + if self.TorRelay.bootstrapPercentage != 100: + # bootstrap timeout + self.stopCore() + # Assign disconnect reason + self.disconnectReason = ( + f'{core}: {_(f"{TorRelay.name()} establish timeout")}' + ) + + logger.error(f'{TorRelay.name()} establish failed') + else: + logger.info(f'{TorRelay.name()} establish success') + def startConnectionTest( self, showRoutingChangedMessage=False, currentRouting='', isBuiltinRouting=False ): diff --git a/Furious/Action/Routing.py b/Furious/Action/Routing.py index 0f18e98..aaca3b2 100644 --- a/Furious/Action/Routing.py +++ b/Furious/Action/Routing.py @@ -95,6 +95,10 @@ BUILTIN_ROUTING_TABLE = { 'mmdb': (DATA_DIR / 'hysteria' / 'country.mmdb').as_posix(), }, }, + 'Route My Traffic Through Tor': { + XrayCore.name(): {}, + Hysteria.name(): {}, + }, 'Global': { XrayCore.name(): { 'domainStrategy': 'IPIfNonMatch', diff --git a/Furious/Action/Settings.py b/Furious/Action/Settings.py index b40d389..963f502 100644 --- a/Furious/Action/Settings.py +++ b/Furious/Action/Settings.py @@ -1,4 +1,4 @@ -from Furious.Gui.Action import Action +from Furious.Gui.Action import Action, Seperator from Furious.Widget.Widget import Menu from Furious.Utility.Constants import APP from Furious.Utility.Utility import bootstrapIcon, Switch @@ -34,6 +34,22 @@ class SettingsChildAction(Action): APP().ServerWidget.showTabAndSpacesIfNecessary() +class RoutingChildAction(Action): + def __init__(self, **kwargs): + super().__init__(_('Routing Settings...'), **kwargs) + + def triggeredCallback(self, checked): + APP().RoutesWidget.show() + + +class TorRelaySettingsChildAction(Action): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def triggeredCallback(self, checked): + APP().torRelaySettingsWidget.open() + + class SettingsAction(Action): def __init__(self): super().__init__( @@ -55,6 +71,10 @@ class SettingsAction(Action): checkable=True, checked=APP().ShowTabAndSpacesInEditor == Switch.ON_, ), + Seperator(), + RoutingChildAction(), + Seperator(), + TorRelaySettingsChildAction(_('Tor Relay Settings...')), ), useActionGroup=False, ) diff --git a/Furious/Core/Core.py b/Furious/Core/Core.py index dcf287d..3095a19 100644 --- a/Furious/Core/Core.py +++ b/Furious/Core/Core.py @@ -63,7 +63,7 @@ class Core: if self.checkAlive(): # Start core daemon - self._daemonTimer.start(100) + self._daemonTimer.start(1) def stop(self): if ( diff --git a/Furious/Core/TorRelay.py b/Furious/Core/TorRelay.py new file mode 100644 index 0000000..0874f9d --- /dev/null +++ b/Furious/Core/TorRelay.py @@ -0,0 +1,150 @@ +from Furious.Core.Core import Core +from Furious.Utility.Constants import ( + APP, + DATA_DIR, + DEFAULT_TOR_SOCKS_PORT, + DEFAULT_TOR_HTTPS_PORT, +) +from Furious.Utility.Utility import AsyncSubprocessMessage + +from PySide6 import QtCore + +import re +import signal +import logging +import threading +import subprocess + +logger = logging.getLogger(__name__) + + +class TorRelayStarter(AsyncSubprocessMessage): + BOOTSTRAP_STATUS = re.compile('Bootstrapped ([0-9]+)%') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.connectTimeoutCallback(self.processLine) + + self.torRelay = None + + self.bootstrapPercentage = 0 + + @property + def torRelayStorageObj(self): + # Handy reference + return APP().torRelaySettingsWidget.StorageObj + + def launch(self, proxyServer): + self.torRelay = subprocess.Popen( + ['tor', '-f', '-'], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + torConfig = ( + f'SocksPort {self.torRelayStorageObj.get("socksTunnelPort", DEFAULT_TOR_SOCKS_PORT)}\n' + f'HTTPTunnelPort {self.torRelayStorageObj.get("httpsTunnelPort", DEFAULT_TOR_HTTPS_PORT)}\n' + f'Log {self.torRelayStorageObj.get("logLevel", "notice")} stdout\n' + f'DataDirectory {DATA_DIR / "tordata"}\n' + ) + + if proxyServer: + logger.info(f'{TorRelay.name()} uses proxy server {proxyServer}') + + # Use proxy + self.torRelay.stdin.write(f'{torConfig}HTTPSProxy {proxyServer}\n'.encode()) + else: + self.torRelay.stdin.write(torConfig.encode()) + + self.torRelay.stdin.close() + + self.startDaemonThread(self.torRelay.stdout) + # Begin get message + self.startTimer() + + @QtCore.Slot() + def processLine(self): + line = self.getLineNoWait() + + if line: + APP().torViewerWidget.textBrowser.append(line) + + match = TorRelayStarter.BOOTSTRAP_STATUS.search(line) + + if match: + percentage = int(match.group(1)) + + self.bootstrapPercentage = percentage + + logger.info(f'{TorRelay.name()} bootstrapped {percentage}%') + + +class TorRelay(Core): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.starter = TorRelayStarter() + + @staticmethod + def checkIfExists(): + try: + subprocess.run( + ['tor', '--version'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + except Exception: + # Any non-exit exceptions + + return False + else: + return True + + @staticmethod + def name(): + return 'Tor Relay' + + @staticmethod + def version(): + try: + result = subprocess.run( + ['tor', '--version'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + + # First line, 3rd param... + return result.stdout.decode().split('\n')[0].split()[2] + except Exception: + # Any non-exit exceptions + + return '0.0.0' + + @property + def bootstrapPercentage(self): + return self.starter.bootstrapPercentage + + def start(self, *args, **kwargs): + logger.info(f'{self.name()} {self.version()} started') + + self.starter.launch(kwargs.get('proxyServer', '')) + + def stop(self): + if isinstance(self.starter.torRelay, subprocess.Popen): + # Stop timer + self.starter.stopTimer() + + # Terminated by signal SIGTERM + self.starter.torRelay.send_signal(signal.SIGTERM) + + exitcode = self.starter.torRelay.wait() + + logger.info(f'{self.name()} terminated with exitcode {exitcode}') + + self.starter.bootstrapPercentage = 0 + # Reset relay + self.starter.torRelay = None diff --git a/Furious/Utility/Constants.py b/Furious/Utility/Constants.py index d8755da..1f0b41a 100644 --- a/Furious/Utility/Constants.py +++ b/Furious/Utility/Constants.py @@ -38,6 +38,13 @@ PROXY_SERVER_BYPASS = ( '172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;192.168.*' ) +TOR_FAQ_URL = 'https://support.torproject.org/faq/' +TOR_FAQ_LABEL = f'Tor FAQ: {TOR_FAQ_URL}' + +# Avoid standard Tor port in case of running Tor services +DEFAULT_TOR_SOCKS_PORT = 9048 +DEFAULT_TOR_HTTPS_PORT = 9047 + class Color: LIGHT_RED_ = '#FF7276' diff --git a/Furious/Utility/Settings.py b/Furious/Utility/Settings.py index fc90c80..540a8e2 100644 --- a/Furious/Utility/Settings.py +++ b/Furious/Utility/Settings.py @@ -65,6 +65,8 @@ SUPPORTED_SETTINGS = ( Settings('Configuration'), # User Activated Server Index Settings('ActivatedItemIndex'), + # User Tor Relay Settings, + Settings('TorRelaySettings'), # Server Widget Window Size Settings('ServerWidgetWindowSize'), # Routes Widget Window Size diff --git a/Furious/Utility/Translator.py b/Furious/Utility/Translator.py index 363ba14..260b90a 100644 --- a/Furious/Utility/Translator.py +++ b/Furious/Utility/Translator.py @@ -1,5 +1,6 @@ from Furious.Core.Core import XrayCore, Hysteria -from Furious.Utility.Constants import APP, APPLICATION_NAME +from Furious.Core.TorRelay import TorRelay +from Furious.Utility.Constants import APP, APPLICATION_NAME, TOR_FAQ_LABEL class Translatable: @@ -87,6 +88,9 @@ NO_TRANSLATION = [ 'TLS', XrayCore.name(), Hysteria.name(), + TOR_FAQ_LABEL, + 'socks', + 'http', ] NO_TRANSLATION_DICT = {key: True for key in NO_TRANSLATION} @@ -133,6 +137,11 @@ TRANSLATION = { 'ZH': '绕过伊朗', 'TW': '繞過伊朗', }, + 'Route My Traffic Through Tor': { + 'ES': 'Enrutar Mi Tráfico a Través de Tor', + 'ZH': '通过Tor路由我的流量', + 'TW': '通過Tor路由我的流量', + }, 'Global': { 'ES': 'Global', 'ZH': '全球', @@ -143,11 +152,6 @@ TRANSLATION = { 'ZH': '自定义', 'TW': '自定義', }, - 'Edit Routing...': { - 'ES': 'Editar Enrutamiento...', - 'ZH': '编辑路由...', - 'TW': '編輯路由...', - }, 'Import': { 'ES': 'Importar', 'ZH': '导入', @@ -193,15 +197,30 @@ TRANSLATION = { 'ZH': '在编辑器中显示制表符和空格', 'TW': '在編輯器中顯示制表符和空格', }, + 'Routing Settings...': { + 'ES': 'Ajustes de Enrutamiento...', + 'ZH': '路由设置...', + 'TW': '路由設置...', + }, + 'Tor Relay Settings...': { + 'ES': 'Ajustes de Tor Relay...', + 'ZH': 'Tor Relay设置...', + 'TW': 'Tor Relay設置...', + }, 'Help': { 'ES': 'Ayuda', 'ZH': '帮助', 'TW': '幫助', }, - 'Show Log...': { - 'ES': 'Mostrar Registro...', - 'ZH': '显示日志...', - 'TW': '顯示日志...', + f'Show {APPLICATION_NAME} Log...': { + 'ES': f'Mostrar Registro de {APPLICATION_NAME}...', + 'ZH': f'显示{APPLICATION_NAME}日志...', + 'TW': f'顯示{APPLICATION_NAME}日志...', + }, + f'Show {TorRelay.name()} Log...': { + 'ES': f'Mostrar Registro de {TorRelay.name()}...', + 'ZH': f'显示{TorRelay.name()}日志...', + 'TW': f'顯示{TorRelay.name()}日志...', }, 'Check For Updates': { 'ES': 'Comprobar Actualizaciones', @@ -771,6 +790,42 @@ TRANSLATION = { 'ZH': '日志查看器', 'TW': '日志查看器', }, + # Tor Relay Settings + 'Tor Relay Settings': { + 'ES': 'Ajustes de Tor Relay', + 'ZH': 'Tor Relay设置', + 'TW': 'Tor Relay設置', + }, + 'Tunnel Port': { + 'ES': 'Puerto de Túnel', + 'ZH': '隧道端口', + 'TW': '隧道端口', + }, + 'Proxy': { + 'ES': 'Proxy', + 'ZH': '代理', + 'TW': '代理', + }, + 'Other': { + 'ES': 'Otro', + 'ZH': '其它', + 'TW': '其它', + }, + 'Use Proxy': { + 'ES': 'Usa Proxy', + 'ZH': '使用代理', + 'TW': '使用代理', + }, + 'Log Level': { + 'ES': 'Nivel de Registro', + 'ZH': '日志级别', + 'TW': '日志級別', + }, + 'Relay Establish Timeout(seconds)': { + 'ES': 'Establecer Tiempo de Espera de Relay(segundos)', + 'ZH': 'Relay建立超时(秒)', + 'TW': 'Relay建立超時(秒)', + }, # Check For Updates 'Check for updates failed.': { 'ES': 'Error al comprobar actualizaciones.', @@ -838,6 +893,16 @@ TRANSLATION = { 'ZH': '路由已更改:', 'TW': '路由已更改:', }, + f'{TorRelay.name()} establish timeout': { + 'ES': f'Establecer tiempo de espera de {TorRelay.name()}', + 'ZH': f'{TorRelay.name()}建立超时', + 'TW': f'{TorRelay.name()}建立超時', + }, + 'Cannot find Tor CLI in PATH': { + 'ES': 'No se puede encontrar Tor CLI en PATH', + 'ZH': '在PATH中找不到Tor CLI', + 'TW': '在PATH中找不到Tor CLI', + }, # Errors f'{APPLICATION_NAME} is not be able to run on this operating system.': { 'ES': f'{APPLICATION_NAME} no puede ejecutarse en este sistema operativo.', diff --git a/Furious/Utility/Utility.py b/Furious/Utility/Utility.py index 03f6d55..6a764b3 100644 --- a/Furious/Utility/Utility.py +++ b/Furious/Utility/Utility.py @@ -1,13 +1,16 @@ from Furious.Gui.Icon import Icon from Furious.Utility.Constants import APP, PLATFORM, ROOT_DIR +from PySide6 import QtCore from PySide6.QtWidgets import QApplication import os import copy import ujson +import queue import logging import pybase64 +import threading import functools import subprocess @@ -118,6 +121,87 @@ class RoutesStorage: APP().CustomRouting = '' +class TorRelaySettingsStorage: + EMPTY_OBJECT = { + 'socksTunnelPort': 9050, + 'httpsTunnelPort': 9049, + 'useProxy': True, + 'logLevel': 'notice', + 'relayEstablishTimeout': 15, + } + + @staticmethod + def init(): + return copy.deepcopy(TorRelaySettingsStorage.EMPTY_OBJECT) + + @staticmethod + def sync(ob=None): + if ob is None: + # Object is up-to-date + APP().TorRelaySettings = TorRelaySettingsStorage.toStorage( + APP().torRelaySettingsWidget.StorageObj + ) + else: + # Object is up-to-date + APP().TorRelaySettings = TorRelaySettingsStorage.toStorage(ob) + + @staticmethod + def toObject(st): + if not st: + # Server storage does not exist, or is empty + return TorRelaySettingsStorage.init() + + return ujson.loads(Base64Encoder.decode(st)) + + @staticmethod + def toStorage(ob): + return Base64Encoder.encode( + ujson.dumps(ob, ensure_ascii=False, escape_forward_slashes=False).encode() + ) + + @staticmethod + def clear(): + APP().TorRelaySettings = '' + + +class AsyncSubprocessMessage: + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.msgQueue = queue.Queue() + self.msgTimer = QtCore.QTimer() + + self.daemonThread = None + + def connectTimeoutCallback(self, callback): + self.msgTimer.timeout.connect(callback) + + def startDaemonThread(self, stdout): + def enqueue(msgQueue): + for line in iter(stdout.readline, b''): + msgQueue.put(line.decode('utf-8', 'replace').strip()) + + stdout.close() + + self.daemonThread = threading.Thread( + target=enqueue, args=(self.msgQueue,), daemon=True + ) + self.daemonThread.start() + + def startTimer(self, msec=1): + self.msgTimer.start(msec) + + def stopTimer(self): + self.msgTimer.stop() + + def getLineNoWait(self): + try: + return self.msgQueue.get_nowait() + except queue.Empty: + # Queue is empty + return '' + + class Switch: OFF = '0' ON_ = '1' diff --git a/Furious/Widget/Application.py b/Furious/Widget/Application.py index fcfaeef..67846c7 100644 --- a/Furious/Widget/Application.py +++ b/Furious/Widget/Application.py @@ -1,7 +1,8 @@ from Furious.Widget.SystemTrayIcon import SystemTrayIcon from Furious.Widget.EditConfiguration import EditConfigurationWidget from Furious.Widget.EditRouting import EditRoutingWidget -from Furious.Widget.LogViewer import LogViewerWidget +from Furious.Widget.LogViewer import LogViewerWidget, TorViewerWidget +from Furious.Widget.TorRelaySettings import TorRelaySettingsWidget from Furious.Utility.Constants import ( APPLICATION_NAME, APPLICATION_VERSION, @@ -142,6 +143,8 @@ class Application(SingletonApplication): self.customFontName = '' self.logViewerWidget = None + self.torViewerWidget = None + self.logViewerHandle = None self.logStreamHandle = None @@ -150,6 +153,7 @@ class Application(SingletonApplication): self.ServerWidget = None self.RoutesWidget = None + self.torRelaySettingsWidget = None def __getattr__(self, key): try: @@ -178,6 +182,8 @@ class Application(SingletonApplication): def configureLogging(self): self.logViewerWidget = LogViewerWidget() + self.torViewerWidget = TorViewerWidget() + self.logViewerHandle = LogViewerHandle(self.logViewerWidget.textBrowser) self.logStreamHandle = logging.StreamHandler() @@ -247,6 +253,7 @@ class Application(SingletonApplication): self.ServerWidget = EditConfigurationWidget() self.RoutesWidget = EditRoutingWidget() + self.torRelaySettingsWidget = TorRelaySettingsWidget() self.tray = SystemTrayIcon() self.tray.show() diff --git a/Furious/Widget/EditConfiguration.py b/Furious/Widget/EditConfiguration.py index 3fb2b64..a6733be 100644 --- a/Furious/Widget/EditConfiguration.py +++ b/Furious/Widget/EditConfiguration.py @@ -1,3 +1,4 @@ +from Furious.Core.TorRelay import TorRelay from Furious.Action.Import import ImportLinkAction, ImportJSONAction from Furious.Action.Export import ExportLinkAction, ExportJSONAction, ExportQRCodeAction from Furious.Core.Intellisense import Intellisense @@ -600,22 +601,22 @@ class ZoomOutAction(Action): self.parent().plainTextEdit.zoomOut() -class RoutingAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Edit Routing...'), **kwargs) - - def triggeredCallback(self, checked): - APP().RoutesWidget.show() - - class ShowLogAction(Action): def __init__(self, **kwargs): - super().__init__(_('Show Log...'), **kwargs) + super().__init__(_(f'Show {APPLICATION_NAME} Log...'), **kwargs) def triggeredCallback(self, checked): APP().logViewerWidget.showMaximized() +class ShowTorRelayLogAction(Action): + def __init__(self, **kwargs): + super().__init__(_(f'Show {TorRelay.name()} Log...'), **kwargs) + + def triggeredCallback(self, checked): + APP().torViewerWidget.showMaximized() + + class WhetherToUpdateInfoBox(MessageBox): def __init__(self, version='0.0.0', *args, **kwargs): super().__init__(*args, **kwargs) @@ -1799,13 +1800,6 @@ class EditConfigurationWidget(MainWindow): ], } - routingMenu = { - 'name': 'Routing', - 'actions': [ - RoutingAction(parent=self), - ], - } - viewMenu = { 'name': 'View', 'actions': [ @@ -1818,6 +1812,7 @@ class EditConfigurationWidget(MainWindow): 'name': 'Help', 'actions': [ ShowLogAction(parent=self), + ShowTorRelayLogAction(parent=self), Seperator(), CheckForUpdatesAction(parent=self), AboutAction(parent=self), @@ -1825,7 +1820,7 @@ class EditConfigurationWidget(MainWindow): } # Menus - for menuDict in (fileMenu, editMenu, routingMenu, viewMenu, helpMenu): + for menuDict in (fileMenu, editMenu, viewMenu, helpMenu): menuName = menuDict['name'] menuObjName = f'_{menuName}Menu' menu = Menu(*menuDict['actions'], title=_(menuName), parent=self.menuBar()) diff --git a/Furious/Widget/EditRouting.py b/Furious/Widget/EditRouting.py index 4802e2c..6ddd3f9 100644 --- a/Furious/Widget/EditRouting.py +++ b/Furious/Widget/EditRouting.py @@ -30,6 +30,8 @@ from Furious.Utility.Constants import ( APPLICATION_NAME, GOLDEN_RATIO, DATA_DIR, + TOR_FAQ_URL, + TOR_FAQ_LABEL, Color, ) from Furious.Utility.Utility import ( @@ -141,6 +143,20 @@ BUILTIN_ROUTING_TEXT = { escape_forward_slashes=False, ), }, + 'Route My Traffic Through Tor': { + XrayCore.name(): ( + f'# Xray-core Routing rules\n' + f'# \n' + f'# This rule is read-only.\n' + f'# \n' + f'# Use Tor to achieve better anonymity, but {APPLICATION_NAME}\n' + f'# never claims nor guarantees *FULL* anonymity.\n' + f'# \n' + f'# See more information:\n' + f'# \n' + f'# {TOR_FAQ_URL}\n\n' + ), + }, 'Global': { XrayCore.name(): ( f'# Xray-core Routing rules\n' @@ -192,6 +208,9 @@ ROUTING_HINT = { 'Bypass Iran': { Hysteria.name(): '', }, + 'Route My Traffic Through Tor': { + Hysteria.name(): TOR_FAQ_LABEL, + }, 'Global': { Hysteria.name(): HYSTERIA_ROUTING_HINT, }, @@ -441,7 +460,7 @@ class EditRoutingAction(Action): if dialog.isBuiltin: # Built-in. Nothing to do logger.info( - f'built-in routing dialog {BUILTIN_ROUTING[index]} called exec. Nothing to do' + f'built-in routing dialog \'{BUILTIN_ROUTING[index]}\' called exec. Nothing to do' ) continue @@ -729,6 +748,11 @@ class RoutingDialog(Translatable, SupportConnectedCallback, QDialog): if hint: self.hint = QLabel(hint) + self.hint.setTextFormat(QtCore.Qt.TextFormat.RichText) + self.hint.setTextInteractionFlags( + QtCore.Qt.TextInteractionFlag.TextBrowserInteraction + ) + self.hint.setOpenExternalLinks(True) layout.addRow(self.hint) else: diff --git a/Furious/Widget/TorRelaySettings.py b/Furious/Widget/TorRelaySettings.py new file mode 100644 index 0000000..72bb472 --- /dev/null +++ b/Furious/Widget/TorRelaySettings.py @@ -0,0 +1,210 @@ +from Furious.Widget.Widget import GroupBox, Label +from Furious.Utility.Constants import ( + APP, + DEFAULT_TOR_SOCKS_PORT, + DEFAULT_TOR_HTTPS_PORT, +) +from Furious.Utility.Utility import ( + StateContext, + SupportConnectedCallback, + TorRelaySettingsStorage, + bootstrapIcon, + moveToCenter, +) +from Furious.Utility.Translator import Translatable, gettext as _ + +from PySide6 import QtCore +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QFormLayout, + QGridLayout, + QSpinBox, + QWidget, +) + + +class TorRelaySettingsWidget(Translatable, SupportConnectedCallback, QDialog): + TOR_LOG_LEVEL = ['err', 'warn', 'notice', 'info', 'debug'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setWindowTitle(_('Tor Relay Settings')) + self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) + + try: + self.StorageObj = TorRelaySettingsStorage.toObject(APP().TorRelaySettings) + except Exception: + # Any non-exit exceptions + + self.StorageObj = TorRelaySettingsStorage.init() + + # Clear it + TorRelaySettingsStorage.clear() + + self.endpointGroupBox = GroupBox(_('Tunnel Port'), parent=self) + self.useProxyGroupBox = GroupBox(_('Proxy'), parent=self) + self.torOtherGroupBox = GroupBox(_('Other'), parent=self) + + # Begin endpoint groupbox + self.socksTunnelLabel = Label(_('socks'), parent=self) + self.httpsTunnelLabel = Label(_('http'), parent=self) + + self.socksTunnelSpinBox = QSpinBox(parent=self) + self.socksTunnelSpinBox.setMinimum(0) + self.socksTunnelSpinBox.setMaximum(65535) + + self.httpsTunnelSpinBox = QSpinBox(parent=self) + self.httpsTunnelSpinBox.setMinimum(0) + self.httpsTunnelSpinBox.setMaximum(65535) + + self.socksTunnelWidget = QWidget(parent=self) + self.socksTunnelLayout = QFormLayout(parent=self.socksTunnelWidget) + self.socksTunnelLayout.addRow(self.socksTunnelLabel, self.socksTunnelSpinBox) + self.socksTunnelWidget.setLayout(self.socksTunnelLayout) + + self.httpsTunnelWidget = QWidget(parent=self) + self.httpsTunnelLayout = QFormLayout(parent=self.httpsTunnelWidget) + self.httpsTunnelLayout.addRow(self.httpsTunnelLabel, self.httpsTunnelSpinBox) + self.httpsTunnelWidget.setLayout(self.httpsTunnelLayout) + + self.endpointGroupBoxLayout = QGridLayout(parent=self.endpointGroupBox) + self.endpointGroupBoxLayout.addWidget(self.socksTunnelWidget, 0, 0) + self.endpointGroupBoxLayout.addWidget(self.httpsTunnelWidget, 0, 1) + + self.endpointGroupBox.setLayout(self.endpointGroupBoxLayout) + # End endpoint groupbox + + # Begin useProxy groupbox + self.useProxyLabel = Label(_('Use Proxy'), parent=self) + + self.useProxyCheckBox = QCheckBox(parent=self) + + self.useProxyWidget = QWidget(parent=self) + self.useProxyLayout = QFormLayout(parent=self.useProxyWidget) + self.useProxyLayout.addRow(self.useProxyLabel, self.useProxyCheckBox) + self.useProxyWidget.setLayout(self.useProxyLayout) + + self.useProxyGroupBoxLayout = QGridLayout(parent=self.useProxyGroupBox) + self.useProxyGroupBoxLayout.addWidget(self.useProxyWidget, 0, 0) + + self.useProxyGroupBox.setLayout(self.useProxyGroupBoxLayout) + # End useProxy groupbox + + # Begin torOther groupbox + self.torLogLevelLabel = Label(_('Log Level'), parent=self) + + self.torLogLevelComboBox = QComboBox(parent=self) + self.torLogLevelComboBox.addItems(TorRelaySettingsWidget.TOR_LOG_LEVEL) + + self.torRelayTimeoutLabel = Label( + _('Relay Establish Timeout(seconds)'), parent=self + ) + + self.torRelayTimeoutSpinBox = QSpinBox(parent=self) + self.torRelayTimeoutSpinBox.setMinimum(0) + self.torRelayTimeoutSpinBox.setMaximum(3600) + + self.torLogLevelWidget = QWidget(parent=self) + self.torLogLevelLayout = QFormLayout(parent=self.torLogLevelWidget) + self.torLogLevelLayout.addRow(self.torLogLevelLabel, self.torLogLevelComboBox) + self.torLogLevelWidget.setLayout(self.torLogLevelLayout) + + self.torRelayTimeoutWidget = QWidget(parent=self) + self.torRelayTimeoutLayout = QFormLayout(parent=self.torRelayTimeoutWidget) + self.torRelayTimeoutLayout.addRow( + self.torRelayTimeoutLabel, self.torRelayTimeoutSpinBox + ) + self.torRelayTimeoutWidget.setLayout(self.torRelayTimeoutLayout) + + self.torOtherGroupBoxLayout = QGridLayout(parent=self.torOtherGroupBox) + self.torOtherGroupBoxLayout.addWidget(self.torLogLevelWidget, 0, 0) + self.torOtherGroupBoxLayout.addWidget(self.torRelayTimeoutWidget, 0, 1) + + self.torOtherGroupBox.setLayout(self.torOtherGroupBoxLayout) + # End torOther groupbox + + # Restore value + self.restoreValueFromObject() + + # Dialog buttons + self.dialogBtns = QDialogButtonBox( + QtCore.Qt.Orientation.Horizontal, parent=self + ) + + self.dialogBtns.addButton(_('OK'), QDialogButtonBox.ButtonRole.AcceptRole) + self.dialogBtns.addButton(_('Cancel'), QDialogButtonBox.ButtonRole.RejectRole) + self.dialogBtns.accepted.connect(self.handleAccepted) + self.dialogBtns.rejected.connect(self.handleRejected) + + # Central Widget + self.fakeCentralWidget = QWidget(parent=self) + + self.layout = QGridLayout(parent=self.fakeCentralWidget) + self.layout.addWidget(self.endpointGroupBox, 0, 0) + self.layout.addWidget(self.useProxyGroupBox, 1, 0) + self.layout.addWidget(self.torOtherGroupBox, 2, 0) + self.layout.addWidget(self.dialogBtns, 3, 0) + + self.setLayout(self.layout) + + @QtCore.Slot() + def handleAccepted(self): + # Sync object + self.StorageObj['socksTunnelPort'] = self.socksTunnelSpinBox.value() + self.StorageObj['httpsTunnelPort'] = self.httpsTunnelSpinBox.value() + self.StorageObj['useProxy'] = self.useProxyCheckBox.isChecked() + self.StorageObj['logLevel'] = self.torLogLevelComboBox.currentText() + self.StorageObj['relayEstablishTimeout'] = self.torRelayTimeoutSpinBox.value() + + # Sync it + TorRelaySettingsStorage.sync() + + self.hide() + + @QtCore.Slot() + def handleRejected(self): + self.restoreValueFromObject() + self.hide() + + def restoreValueFromObject(self): + # Restore value + self.socksTunnelSpinBox.setValue( + self.StorageObj.get('socksTunnelPort', DEFAULT_TOR_SOCKS_PORT) + ) + self.httpsTunnelSpinBox.setValue( + self.StorageObj.get('httpsTunnelPort', DEFAULT_TOR_HTTPS_PORT) + ) + + self.useProxyCheckBox.setChecked(self.StorageObj.get('useProxy', True)) + + self.torLogLevelComboBox.setCurrentText( + self.StorageObj.get('logLevel', 'notice') + ) + self.torRelayTimeoutSpinBox.setValue( + self.StorageObj.get('relayEstablishTimeout', 15) + ) + + def closeEvent(self, event): + event.ignore() + + self.restoreValueFromObject() + self.hide() + + def connectedCallback(self): + self.setWindowIcon(bootstrapIcon('rocket-takeoff-connected-dark.svg')) + + def disconnectedCallback(self): + self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) + + def retranslate(self): + with StateContext(self): + self.setWindowTitle(_(self.windowTitle())) + + for button in self.dialogBtns.buttons(): + button.setText(_(button.text())) + + moveToCenter(self) diff --git a/Furious/Widget/Widget.py b/Furious/Widget/Widget.py index 3ed8fb8..3fb3df8 100644 --- a/Furious/Widget/Widget.py +++ b/Furious/Widget/Widget.py @@ -12,8 +12,10 @@ from Furious.Utility.Translator import Translatable, gettext as _ from PySide6 import QtCore from PySide6.QtGui import QBrush, QColor, QFont, QTextCursor from PySide6.QtWidgets import ( + QGroupBox, QHeaderView, QInputDialog, + QLabel, QLineEdit, QListWidget, QMainWindow, @@ -29,6 +31,15 @@ from PySide6.QtWidgets import ( ) +class GroupBox(Translatable, QGroupBox): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def retranslate(self): + with StateContext(self): + self.setTitle(_(self.title())) + + class HeaderView(SupportConnectedCallback, QHeaderView): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -50,6 +61,15 @@ class HeaderView(SupportConnectedCallback, QHeaderView): ) +class Label(Translatable, QLabel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def retranslate(self): + with StateContext(self): + self.setText(_(self.text())) + + class ListWidget(SupportConnectedCallback, QListWidget): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs)