Files
LorenEteval_Furious/Furious/Action/Connect.py
T
2023-07-24 19:49:17 +08:00

670 lines
22 KiB
Python

from Furious.Core.Core import XrayCore, Hysteria
from Furious.Core.Intellisense import Intellisense
from Furious.Core.Configuration import Configuration
from Furious.Action.Routing import SUPPORTED_ROUTING_DETAIL
from Furious.Gui.Action import Action
from Furious.Widget.ConnectingProgressBar import ConnectingProgressBar
from Furious.Widget.Widget import MessageBox
from Furious.Utility.Constants import APPLICATION_NAME, PROXY_SERVER_BYPASS
from Furious.Utility.Utility import (
Switch,
SupportConnectedCallback,
bootstrapIcon,
getAbsolutePath,
)
from Furious.Utility.Translator import gettext as _
from Furious.Utility.Proxy import Proxy
from PySide6 import QtCore
from PySide6.QtWidgets import QApplication
from PySide6.QtNetwork import (
QNetworkAccessManager,
QNetworkReply,
QNetworkRequest,
QNetworkProxy,
)
import ujson
import random
import logging
logger = logging.getLogger(__name__)
class HttpProxyServerError(Exception):
pass
class ConnectAction(Action):
def __init__(self):
super().__init__(
_('Connect'),
icon=bootstrapIcon('unlock-fill.svg'),
checkable=True,
)
self.connectingProgressBar = ConnectingProgressBar()
self.configurationEmptyBox = MessageBox()
self.configurationIcingBox = MessageBox()
self.configurationErrorBox = MessageBox()
self.configurationTampered = MessageBox()
self.httpProxyConfErrorBox = MessageBox()
self.proxyServer = ''
self.networkAccessManager = QNetworkAccessManager(parent=self)
self.networkReply = None
self.coreName = ''
self.coreText = ''
self.coreJSON = {}
self.coreRunning = False
self.XrayRouting = {}
self.connectingFlag = False
# Note: The connection test is carried out item by item
# from top to bottom. If any of these succeed,
# connected action will be executed.
# "Popular" sites that's been endorsed by some government.
self.testPool = [
# Messaging
'https://telegram.org/',
# Social media
'https://twitter.com/',
# Videos
'https://www.youtube.com/',
]
self.testTime = 0
self.XrayCore = XrayCore()
self.Hysteria = Hysteria()
def XrayCoreExitCallback(self, exitcode):
if self.coreName:
# If core is running
assert self.coreRunning
assert self.coreName == XrayCore.name()
if exitcode == XrayCore.ExitCode.ConfigurationError:
if not self.isConnecting():
# Protect connecting action. Mandatory
return self.disconnectAction(
f'{XrayCore.name()}: {_("Invalid server configuration")}'
)
if exitcode == XrayCore.ExitCode.ServerStartFailure:
if not self.isConnecting():
# Protect connecting action. Mandatory
return self.disconnectAction(
f'{XrayCore.name()}: {_("Failed to start core")}'
)
if not self.isConnecting():
# Protect connecting action. Mandatory
self.disconnectAction(
f'{XrayCore.name()}: {_("Core terminated unexpectedly")}'
)
def HysteriaExitCallback(self, exitcode):
if self.coreName:
# If core is running
assert self.coreRunning
assert self.coreName == Hysteria.name()
if exitcode == Hysteria.ExitCode.ConfigurationError:
if not self.isConnecting():
# Protect connecting action. Mandatory
return self.disconnectAction(
f'{Hysteria.name()}: {_("Invalid server configuration")}'
)
if exitcode == Hysteria.ExitCode.RemoteNetworkError:
if not self.isConnecting():
# Protect connecting action. Mandatory
return self.disconnectAction(
f'{Hysteria.name()}: {_("Connection to server has been lost")}'
)
if not self.isConnecting():
# Protect connecting action. Mandatory
self.disconnectAction(
f'{Hysteria.name()}: {_("Core terminated unexpectedly")}'
)
def stopCore(self):
# Stop any potentially running core
self.XrayCore.stop()
self.Hysteria.stop()
def showConnectingProgressBar(self):
if QApplication.instance().ShowProgressBarWhenConnecting == Switch.ON_:
self.connectingProgressBar.progressBar.setValue(0)
# Update the progress bar every 50ms
self.connectingProgressBar.timer.start(50)
self.connectingProgressBar.show()
return self
def hideConnectingProgressBar(self, done=False):
if done:
self.connectingProgressBar.progressBar.setValue(100)
self.connectingProgressBar.hide()
self.connectingProgressBar.timer.stop()
return self
def moveConnectingProgressBar(self):
# Progressing
if self.connectingProgressBar.progressBar.value() <= 45:
self.connectingProgressBar.progressBar.setValue(random.randint(45, 50))
# Lower timer frequency
self.connectingProgressBar.timer.start(250)
return self
def setDisabledAction(self, value):
self.setDisabled(value)
QApplication.instance().tray.RoutingAction.setDisabled(value)
def setConnectingStatus(self, showProgressBar=True):
if showProgressBar:
self.showConnectingProgressBar()
# Do not accept new action
self.setDisabledAction(True)
self.setText(_('Connecting'))
self.setIcon(bootstrapIcon('lock-fill.svg'))
QApplication.instance().tray.setPlainIcon()
def setConnectedStatus(self):
self.hideConnectingProgressBar(done=True)
self.setDisabledAction(False)
QApplication.instance().tray.setConnectedIcon()
# Finished. Reset connecting flag
self.connectingFlag = False
# Connected
self.setText(_('Disconnect'))
SupportConnectedCallback.callConnectedCallback()
def isConnecting(self):
return self.connectingFlag
def isConnected(self):
return self.textCompare('Disconnect')
def reset(self):
# Reset everything
self.XrayCore.registerExitCallback(None)
self.Hysteria.registerExitCallback(None)
self.stopCore()
self.hideConnectingProgressBar()
self.setText(_('Connect'))
self.setIcon(bootstrapIcon('unlock-fill.svg'))
self.setChecked(False)
QApplication.instance().Connect = Switch.OFF
QApplication.instance().tray.setPlainIcon()
self.proxyServer = ''
self.coreName = ''
self.coreText = ''
self.coreJSON = {}
self.coreRunning = False
self.XrayRouting = {}
# Accept new action
self.setDisabledAction(False)
self.connectingFlag = False
@property
def MainWidget(self):
# Handy reference
return QApplication.instance().MainWidget
@property
def activatedServer(self):
try:
activatedIndex = int(QApplication.instance().ActivatedItemIndex)
if activatedIndex < 0:
return None
else:
return self.MainWidget.ServerList[activatedIndex]['config']
except Exception:
# Any non-exit exceptions
return None
def errorConfiguration(self):
self.configurationErrorBox.setIcon(MessageBox.Icon.Critical)
self.configurationErrorBox.setWindowTitle(_('Unable to connect'))
self.configurationErrorBox.setText(_('Invalid server configuration.'))
# Show the MessageBox and wait for user to close it
self.configurationErrorBox.exec()
def errorConfigurationEmpty(self):
self.configurationEmptyBox.setIcon(MessageBox.Icon.Critical)
self.configurationEmptyBox.setWindowTitle(_('Unable to connect'))
self.configurationEmptyBox.setText(
_('Server configuration empty. Please configure your server first.')
)
# Show the MessageBox and wait for user to close it
self.configurationEmptyBox.exec()
def errorConfigurationNotActivated(self):
self.configurationIcingBox.setIcon(MessageBox.Icon.Information)
self.configurationIcingBox.setWindowTitle(_('Unable to connect'))
self.configurationIcingBox.setText(
_('Select and double click to activate configuration and connect.')
)
# Show the MessageBox and wait for user to close it
self.configurationIcingBox.exec()
def errorHttpProxyConf(self):
self.httpProxyConfErrorBox.setIcon(MessageBox.Icon.Critical)
self.httpProxyConfErrorBox.setWindowTitle(_('Unable to connect'))
self.httpProxyConfErrorBox.setText(
_(
f'{APPLICATION_NAME} cannot find any valid http proxy '
f'endpoint in your server configuration.'
)
)
self.httpProxyConfErrorBox.setInformativeText(
_('Please complete your server configuration.')
)
# Show the MessageBox and wait for user to close it
self.httpProxyConfErrorBox.exec()
def configureCore(self):
def validateProxyServer(server):
# Validate proxy server
try:
host, port = server.split(':')
if int(port) < 0 or int(port) > 65535:
raise ValueError
except Exception:
# Any non-exit exceptions
self.reset()
self.errorHttpProxyConf()
return False
else:
self.proxyServer = server
return True
self.stopCore()
self.XrayCore.registerExitCallback(
lambda exitcode: self.XrayCoreExitCallback(exitcode)
)
self.Hysteria.registerExitCallback(
lambda exitcode: self.HysteriaExitCallback(exitcode)
)
proxyServer = None
if Intellisense.getCoreType(self.coreJSON) == XrayCore.name():
# Assuming is XrayCore configuration
proxyHost = None
proxyPort = None
try:
for inbound in self.coreJSON['inbounds']:
if inbound['protocol'] == 'http':
proxyHost = inbound['listen']
proxyPort = inbound['port']
# Note: If there are multiple http inbounds
# satisfied, the first one will be chosen.
break
if proxyHost is None or proxyPort is None:
# No HTTP proxy endpoint configured
raise HttpProxyServerError
proxyServer = f'{proxyHost}:{proxyPort}'
except (KeyError, HttpProxyServerError):
self.reset()
self.errorHttpProxyConf()
else:
if validateProxyServer(proxyServer):
self.coreRunning = True
routing = QApplication.instance().Routing
logger.info(f'Core {XrayCore.name()} configured')
logger.info(f'Routing is {routing}')
def fixLoggingRelativePath(attr):
# Relative path fails if booting on start up
# on Windows, when packed using nuitka...
# Fix relative path if needed. User cannot feel this operation.
try:
path = self.coreJSON['log'][attr]
except KeyError:
pass
else:
if path and (
isinstance(path, str) or isinstance(path, bytes)
):
fix = getAbsolutePath(path)
logger.info(
f'{XrayCore.name()}: {attr} log is specified as \'{path}\'. '
f'Fixed to \'{fix}\''
)
self.coreJSON['log'][attr] = fix
fixLoggingRelativePath('access')
fixLoggingRelativePath('error')
if routing == 'Bypass':
self.coreJSON['routing'] = {
'domainStrategy': 'IPIfNonMatch',
'domainMatcher': 'hybrid',
'rules': (
# ads
{
'type': 'field',
'outboundTag': 'block',
'domain': ['geosite:category-ads-all'],
},
# geosite
{
'type': 'field',
'outboundTag': 'direct',
'domain': ['geosite:cn'],
},
# geoip
{
'type': 'field',
'outboundTag': 'direct',
'ip': ['geoip:private', 'geoip:cn'],
},
# Proxy everything
{
'type': 'field',
'port': '0-65535',
'outboundTag': 'proxy',
},
),
}
if routing == 'Global':
self.coreJSON['routing'] = {
'domainStrategy': 'IPIfNonMatch',
'domainMatcher': 'hybrid',
'rules': (
# Proxy everything
{
'type': 'field',
'port': '0-65535',
'outboundTag': 'proxy',
},
),
}
if routing == 'Custom':
# Assign user routing
self.coreJSON['routing'] = self.XrayRouting
# Refresh configuration modified before. User cannot feel
self.coreText = ujson.dumps(
self.coreJSON, ensure_ascii=False, escape_forward_slashes=False
)
# Start core
self.XrayCore.start(self.coreText)
return XrayCore.name()
if Intellisense.getCoreType(self.coreJSON) == Hysteria.name():
# Assuming is Hysteria configuration
try:
proxyServer = self.coreJSON['http']['listen']
if proxyServer is None:
# No HTTP proxy endpoint configured
raise HttpProxyServerError
except (KeyError, HttpProxyServerError):
self.reset()
self.errorHttpProxyConf()
else:
if validateProxyServer(proxyServer):
self.coreRunning = True
routing = QApplication.instance().Routing
logger.info(f'Core {Hysteria.name()} configured')
logger.info(f'Routing is {routing}')
if routing == 'Bypass':
self.Hysteria.start(
self.coreText, Hysteria.rule(), Hysteria.mmdb()
)
if routing == 'Global':
self.Hysteria.start(self.coreText, '', '')
if routing == 'Custom':
if self.coreJSON.get('acl') is not None:
# acl specified
if self.coreJSON.get('mmdb') is not None:
# mmdb specified. Start "as is"
self.Hysteria.start(
self.coreText,
Hysteria.rule(self.coreJSON['acl']),
Hysteria.mmdb(self.coreJSON['mmdb']),
)
else:
# mmdb not specified. Route as global
self.Hysteria.start(self.coreText, '', '')
else:
# acl not specified. Route as global
self.Hysteria.start(self.coreText, '', '')
return Hysteria.name()
# No matching core
return ''
def startConnectionTest(self, showRoutingChangedMessage=False):
selected = self.testPool[self.testTime]
self.testTime += 1
logger.info(f'start connection test. Try: {selected}')
# Checked. split should not throw exceptions
proxyHost, proxyPort = self.proxyServer.split(':')
# Checked. int(proxyPort) should not throw exceptions
self.networkAccessManager.setProxy(
QNetworkProxy(QNetworkProxy.ProxyType.HttpProxy, proxyHost, int(proxyPort))
)
self.networkReply = self.networkAccessManager.get(
QNetworkRequest(QtCore.QUrl(selected))
)
@QtCore.Slot()
def finishedCallback():
assert isinstance(self.networkReply, QNetworkReply)
if self.networkReply.error() != QNetworkReply.NetworkError.NoError:
logger.error(
f'{self.coreName}: connection test failed. {self.networkReply.errorString()}'
)
if self.testTime < len(self.testPool):
# Try next
self.startConnectionTest(showRoutingChangedMessage)
else:
self.disconnectAction(
f'{self.coreName}: {_("Connection test failed")}'
)
else:
logger.info(f'{self.coreName}: connection test success. Connected')
QApplication.instance().Connect = Switch.ON_
# Connected status
self.setConnectedStatus()
if showRoutingChangedMessage:
# Routing changed
QApplication.instance().tray.showMessage(
_('Routing changed: ')
+ _(
f'{SUPPORTED_ROUTING_DETAIL[QApplication.instance().Routing]}'
)
)
else:
# Connected
QApplication.instance().tray.showMessage(
f'{self.coreName}: {_("Connected")}'
)
self.networkReply.finished.connect(finishedCallback)
def connectAction(self):
# Connect action
assert self.textCompare('Connect')
# Connecting
self.connectingFlag = True
if (
not QApplication.instance().Configuration
or len(self.MainWidget.ServerList) == 0
):
QApplication.instance().Connect = Switch.OFF
self.setChecked(False)
self.connectingFlag = False
self.errorConfigurationEmpty()
return
myText = self.activatedServer
if myText is None:
QApplication.instance().Connect = Switch.OFF
self.setChecked(False)
self.connectingFlag = False
self.errorConfigurationNotActivated()
return
if myText == '':
QApplication.instance().Connect = Switch.OFF
self.setChecked(False)
self.connectingFlag = False
self.errorConfigurationEmpty()
return
try:
myJSON = Configuration.toJSON(myText)
except Exception:
# Any non-exit exceptions
QApplication.instance().Connect = Switch.OFF
self.setChecked(False)
self.connectingFlag = False
# Invalid configuratoin
self.errorConfiguration()
else:
# Get server configuration success. Continue.
# Note: use self.reset() to restore state
self.coreText = myText
self.coreJSON = myJSON
# Memorize user routing if possible
self.XrayRouting = myJSON.get('routing', {})
self.connectingAction()
def connectingAction(self, showProgressBar=True, showRoutingChangedMessage=False):
# Connecting status
self.setConnectingStatus(showProgressBar)
# Configure connect
self.coreName = self.configureCore()
if not self.coreName:
# No matching core
self.reset()
self.errorConfiguration()
return
if not self.coreRunning:
# No valid HTTP proxy endpoint / Core has exited.
# reset / disconnect has been called
return
try:
Proxy.set(self.proxyServer, PROXY_SERVER_BYPASS)
except Exception:
# Any non-exit exceptions
Proxy.off()
self.reset()
self.errorConfiguration()
else:
self.moveConnectingProgressBar()
# Reset try time
self.testTime = 0
self.startConnectionTest(showRoutingChangedMessage)
def disconnectAction(self, reason=''):
Proxy.off()
self.reset()
SupportConnectedCallback.callDisconnectedCallback()
QApplication.instance().tray.showMessage(reason)
def reconnectAction(self, reason=''):
self.disconnectAction(reason)
self.trigger()
def triggeredCallback(self, checked):
if checked:
self.connectAction()
else:
# Disconnect action
assert self.textCompare('Disconnect')
assert self.connectingFlag is False
self.disconnectAction(f'{self.coreName}: {_("Disconnected")}')