mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-10-07 06:18:05 +03:00
Add native Hysteria2 TUN support
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Vendored
+27
@@ -2213,6 +2213,7 @@ TRANSLATION = {
|
||||
},
|
||||
"Interface": {
|
||||
"source": [
|
||||
"Furious.Plugins.Official.Hysteria2.GuiTUNSettings",
|
||||
"Furious.Plugins.Official.Xray.GuiTUNSettings"
|
||||
],
|
||||
"RU": "Интерфейс",
|
||||
@@ -2221,6 +2222,7 @@ TRANSLATION = {
|
||||
},
|
||||
"Network": {
|
||||
"source": [
|
||||
"Furious.Plugins.Official.Hysteria2.GuiTUNSettings",
|
||||
"Furious.Plugins.Official.Xray.GuiTUNSettings"
|
||||
],
|
||||
"RU": "Сеть",
|
||||
@@ -2277,10 +2279,35 @@ TRANSLATION = {
|
||||
},
|
||||
"TUN Documentation": {
|
||||
"source": [
|
||||
"Furious.Plugins.Official.Hysteria2.GuiTUNSettings",
|
||||
"Furious.Plugins.Official.Xray.GuiTUNSettings"
|
||||
],
|
||||
"RU": "Документация TUN",
|
||||
"ZH": "TUN文档",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Use Hysteria2 TUN": {
|
||||
"source": [
|
||||
"Furious.Plugins.Official.Hysteria2.Plugin"
|
||||
],
|
||||
"RU": "Использовать TUN Hysteria2",
|
||||
"ZH": "使用Hysteria2 TUN",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Customize Hysteria2 TUN Settings": {
|
||||
"source": [
|
||||
"Furious.Plugins.Official.Hysteria2.GuiTUNSettings"
|
||||
],
|
||||
"RU": "Настроить параметры TUN Hysteria2",
|
||||
"ZH": "自定义Hysteria2 TUN设置",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Customize Hysteria2 TUN Settings...": {
|
||||
"source": [
|
||||
"Furious.Plugins.Official.Hysteria2.Plugin"
|
||||
],
|
||||
"RU": "Настроить параметры TUN Hysteria2...",
|
||||
"ZH": "自定义Hysteria2 TUN设置...",
|
||||
"isReviewed": "True"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""Provide the editor for Hysteria 2 native TUN settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Frozenlib import *
|
||||
from Furious.Qt import *
|
||||
from Furious.Qt import gettext as _
|
||||
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import *
|
||||
|
||||
import copy
|
||||
import functools
|
||||
import logging
|
||||
|
||||
from .Plugin import isHysteria2ConnectionActive
|
||||
from .TUN import getHysteria2TUNSettings, saveHysteria2TUNSettings
|
||||
|
||||
__all__ = ['GuiHysteria2TUNSettings']
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _nestedValue(config: dict, path: tuple[str, ...], default):
|
||||
"""Return a nested settings value or *default*."""
|
||||
value = config
|
||||
for key in path:
|
||||
if not isinstance(value, dict):
|
||||
return default
|
||||
|
||||
value = value.get(key, default)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _setNestedValue(config: dict, path: tuple[str, ...], value):
|
||||
"""Set one nested settings value."""
|
||||
target = config
|
||||
for key in path[:-1]:
|
||||
child = target.get(key)
|
||||
if not isinstance(child, dict):
|
||||
child = {}
|
||||
target[key] = child
|
||||
|
||||
target = child
|
||||
|
||||
target[path[-1]] = value
|
||||
|
||||
|
||||
class GuiHysteria2TUNItemText(GuiEditorItemTextInput):
|
||||
"""Edit one string-valued Hysteria 2 TUN setting."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize a Hysteria 2 TUN string editor."""
|
||||
self.path = tuple(kwargs.pop('path'))
|
||||
maximumWidth = kwargs.pop('maximumWidth', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if maximumWidth is not None:
|
||||
self._input.setMaximumWidth(maximumWidth)
|
||||
|
||||
def inputToFactory(self, config: dict) -> bool:
|
||||
"""Store the current text in a Hysteria 2 TUN settings mapping."""
|
||||
value = self.text().strip()
|
||||
if _nestedValue(config, self.path, '') == value:
|
||||
return False
|
||||
|
||||
_setNestedValue(config, self.path, value)
|
||||
|
||||
return True
|
||||
|
||||
def factoryToInput(self, config: dict):
|
||||
"""Load one Hysteria 2 TUN string setting into the editor."""
|
||||
value = _nestedValue(config, self.path, '')
|
||||
self.setText(value if isinstance(value, str) else '')
|
||||
|
||||
|
||||
class GuiHysteria2TUNItemList(GuiHysteria2TUNItemText):
|
||||
"""Edit one list-valued TUN setting as comma-separated text."""
|
||||
|
||||
def inputToFactory(self, config: dict) -> bool:
|
||||
"""Store comma-separated values in the settings mapping."""
|
||||
value = [item.strip() for item in self.text().split(',') if item.strip()]
|
||||
if _nestedValue(config, self.path, []) == value:
|
||||
return False
|
||||
|
||||
_setNestedValue(config, self.path, value)
|
||||
|
||||
return True
|
||||
|
||||
def factoryToInput(self, config: dict):
|
||||
"""Load one list setting into the editor."""
|
||||
value = _nestedValue(config, self.path, [])
|
||||
self.setText(','.join(value) if isinstance(value, list) else '')
|
||||
|
||||
|
||||
class GuiHysteria2TUNItemMTU(GuiEditorItemTextSpinBox):
|
||||
"""Edit the native TUN MTU."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the MTU editor."""
|
||||
super().__init__(*args, **kwargs)
|
||||
self.setRange(1, 65535)
|
||||
|
||||
def inputToFactory(self, config: dict) -> bool:
|
||||
"""Store the MTU in the settings mapping."""
|
||||
value = self.value()
|
||||
if config.get('mtu', 1500) == value:
|
||||
return False
|
||||
|
||||
config['mtu'] = value
|
||||
|
||||
return True
|
||||
|
||||
def factoryToInput(self, config: dict):
|
||||
"""Load the MTU into the editor."""
|
||||
value = config.get('mtu', 1500)
|
||||
self.setValue(value if isinstance(value, int) else 1500)
|
||||
|
||||
|
||||
class GuiHysteria2TUNSettingsGroupBoxInterface(GuiEditorWidgetQGroupBox):
|
||||
"""Edit Hysteria 2 TUN interface settings."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize the interface settings group."""
|
||||
super().__init__(_('Interface'), **kwargs)
|
||||
|
||||
def containerSequence(self):
|
||||
"""Return interface-setting editors in display order."""
|
||||
return [
|
||||
GuiHysteria2TUNItemText(
|
||||
title='name',
|
||||
path=('name',),
|
||||
maximumWidth=360,
|
||||
translatable=False,
|
||||
),
|
||||
GuiHysteria2TUNItemMTU(title='mtu', translatable=False),
|
||||
GuiHysteria2TUNItemText(
|
||||
title='timeout',
|
||||
path=('timeout',),
|
||||
maximumWidth=360,
|
||||
translatable=False,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class GuiHysteria2TUNSettingsGroupBoxNetwork(GuiEditorWidgetQGroupBox):
|
||||
"""Edit Hysteria 2 TUN addresses and routes."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize the network settings group."""
|
||||
super().__init__(_('Network'), **kwargs)
|
||||
|
||||
def containerSequence(self):
|
||||
"""Return address and route editors in display order."""
|
||||
return [
|
||||
GuiHysteria2TUNItemText(
|
||||
title='address.ipv4',
|
||||
path=('address', 'ipv4'),
|
||||
translatable=False,
|
||||
),
|
||||
GuiHysteria2TUNItemText(
|
||||
title='address.ipv6',
|
||||
path=('address', 'ipv6'),
|
||||
translatable=False,
|
||||
),
|
||||
GuiHysteria2TUNItemList(
|
||||
title='route.ipv4',
|
||||
path=('route', 'ipv4'),
|
||||
translatable=False,
|
||||
),
|
||||
GuiHysteria2TUNItemList(
|
||||
title='route.ipv6',
|
||||
path=('route', 'ipv6'),
|
||||
translatable=False,
|
||||
),
|
||||
GuiHysteria2TUNItemList(
|
||||
title='route.ipv4Exclude',
|
||||
path=('route', 'ipv4Exclude'),
|
||||
translatable=False,
|
||||
),
|
||||
GuiHysteria2TUNItemList(
|
||||
title='route.ipv6Exclude',
|
||||
path=('route', 'ipv6Exclude'),
|
||||
translatable=False,
|
||||
),
|
||||
]
|
||||
|
||||
def setupPageLayout(self):
|
||||
"""Arrange related IPv4 and IPv6 settings in paired rows."""
|
||||
layout = QGridLayout()
|
||||
layout.setColumnStretch(1, 1)
|
||||
layout.setColumnStretch(3, 1)
|
||||
|
||||
for index, container in enumerate(self._containers):
|
||||
row, field = divmod(index, 2)
|
||||
label, editor = container.widgets()
|
||||
column = field * 2
|
||||
layout.addWidget(label, row, column)
|
||||
layout.addWidget(editor, row, column + 1)
|
||||
|
||||
return layout
|
||||
|
||||
|
||||
class Hysteria2TUNDocumentationURL(AppQLabel):
|
||||
"""Link to the official Hysteria 2 TUN documentation."""
|
||||
|
||||
URL = 'https://hysteria.network/docs/advanced/Full-Client-Config/#tun'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the Hysteria 2 TUN documentation link."""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWebsiteURL()
|
||||
self.linkActivated.connect(self.handleLinkActivated)
|
||||
|
||||
def setWebsiteURL(self):
|
||||
"""Set the translated documentation hyperlink text."""
|
||||
self.setText(f'<a href="{self.URL}">' + _('TUN Documentation') + '</a>')
|
||||
|
||||
@staticmethod
|
||||
def handleLinkActivated(link: str):
|
||||
"""Open the Hysteria 2 TUN documentation in the default browser."""
|
||||
if QDesktopServices.openUrl(QtCore.QUrl(link)):
|
||||
logger.info(f'open link {link!r} success')
|
||||
else:
|
||||
logger.error(f'open link {link!r} failed')
|
||||
|
||||
def retranslate(self):
|
||||
"""Refresh the translated documentation hyperlink text."""
|
||||
self.setWebsiteURL()
|
||||
|
||||
|
||||
class GuiHysteria2TUNSettings(GuiEditorWidgetQDialog):
|
||||
"""Edit and persist Hysteria 2 native TUN settings."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the Hysteria 2 TUN settings dialog."""
|
||||
kwargs.setdefault('style', 'portrait')
|
||||
kwargs.setdefault('tabTranslatable', True)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setTabText(_('Customize Hysteria2 TUN Settings'))
|
||||
self.setFixedSize(int(650 * GOLDEN_RATIO), 650)
|
||||
self._settings = getHysteria2TUNSettings()
|
||||
self.factoryToInput(self._settings)
|
||||
self.accepted.connect(self.handleAccepted)
|
||||
|
||||
self.layout().takeRow(self.layout().rowCount() - 1)
|
||||
bottomLayout = QHBoxLayout()
|
||||
bottomLayout.addWidget(Hysteria2TUNDocumentationURL())
|
||||
bottomLayout.addStretch(1)
|
||||
bottomLayout.addWidget(self.dialogBtns)
|
||||
self.layout().addRow(bottomLayout)
|
||||
|
||||
def handleAccepted(self):
|
||||
"""Persist changed settings and offer to reconnect when connected."""
|
||||
oldSettings = copy.deepcopy(self._settings)
|
||||
self.inputToFactory(self._settings)
|
||||
if self._settings == oldSettings:
|
||||
return
|
||||
|
||||
saveHysteria2TUNSettings(self._settings)
|
||||
if SystemRuntime.isTUNMode() and isHysteria2ConnectionActive():
|
||||
showMBoxNewChangesNextTime()
|
||||
|
||||
@functools.lru_cache(None)
|
||||
def groupBoxSequence(self):
|
||||
"""Return Hysteria 2 TUN setting groups in display order."""
|
||||
return [
|
||||
GuiHysteria2TUNSettingsGroupBoxInterface(),
|
||||
GuiHysteria2TUNSettingsGroupBoxNetwork(),
|
||||
]
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Frozenlib import APP, PLATFORM, SystemRuntime
|
||||
from Furious.Qt.DynamicTranslate import gettext as _
|
||||
from Furious.Plugins.API import FuriousPlugin, PluginProtocol
|
||||
from Furious.Plugins.Official.Configuration import (
|
||||
@@ -30,8 +31,15 @@ import copy
|
||||
import logging
|
||||
|
||||
from .Core import Hysteria2
|
||||
from .TUN import (
|
||||
buildHysteria2TUNConfig,
|
||||
getHysteria2TUNSettings,
|
||||
isHysteria2TUNEnabled,
|
||||
resolveHysteria2ServerAddresses,
|
||||
setHysteria2TUNEnabled,
|
||||
)
|
||||
|
||||
__all__ = ['Hysteria2Plugin']
|
||||
__all__ = ['Hysteria2Plugin', 'isHysteria2ConnectionActive']
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -40,6 +48,22 @@ _TRANSLATABLE_ACTION_TEXT = [
|
||||
]
|
||||
|
||||
|
||||
def isHysteria2ConnectionActive() -> bool:
|
||||
"""Return whether the connected core process belongs to Hysteria 2."""
|
||||
try:
|
||||
connectAction = APP().systemTray.ConnectAction
|
||||
if not connectAction.isConnected():
|
||||
return False
|
||||
|
||||
return any(
|
||||
isinstance(process, Hysteria2)
|
||||
for process in connectAction.coreManager.processesPool
|
||||
)
|
||||
except Exception:
|
||||
# Plugin actions can be created before the tray is fully initialized.
|
||||
return False
|
||||
|
||||
|
||||
class Hysteria2Plugin(FuriousPlugin):
|
||||
"""Provide official Hysteria 2 support."""
|
||||
|
||||
@@ -85,6 +109,7 @@ class Hysteria2Plugin(FuriousPlugin):
|
||||
'udpForwarding',
|
||||
'tcpTProxy',
|
||||
'udpTProxy',
|
||||
'tun',
|
||||
'fastOpen',
|
||||
'lazy',
|
||||
)
|
||||
@@ -106,6 +131,77 @@ class Hysteria2Plugin(FuriousPlugin):
|
||||
|
||||
return GuiHysteria2(parent=parent, **kwargs)
|
||||
|
||||
def createManagementActions(self, parent=None, **kwargs):
|
||||
"""Create Hysteria 2 native TUN management actions."""
|
||||
# These modules require a fully initialized Furious.Qt package.
|
||||
from Furious.Qt import AppQAction, showMBoxNewChangesNextTime
|
||||
from Furious.Qt import gettext as _
|
||||
|
||||
from .GuiTUNSettings import GuiHysteria2TUNSettings
|
||||
|
||||
useHysteria2TUNAction = AppQAction(
|
||||
_('Use Hysteria2 TUN'),
|
||||
checkable=True,
|
||||
checked=isHysteria2TUNEnabled(),
|
||||
)
|
||||
|
||||
def updateUseHysteria2TUN():
|
||||
"""Persist the native Hysteria 2 TUN action state."""
|
||||
setHysteria2TUNEnabled(useHysteria2TUNAction.isChecked())
|
||||
|
||||
if SystemRuntime.isTUNMode() and isHysteria2ConnectionActive():
|
||||
showMBoxNewChangesNextTime()
|
||||
|
||||
useHysteria2TUNAction.callback = updateUseHysteria2TUN
|
||||
|
||||
return (
|
||||
useHysteria2TUNAction,
|
||||
AppQAction(
|
||||
_('Customize Hysteria2 TUN Settings...'),
|
||||
callback=lambda: GuiHysteria2TUNSettings(parent=parent).open(),
|
||||
),
|
||||
)
|
||||
|
||||
def prepareTUN(self, config) -> bool:
|
||||
"""Add Hysteria 2 native TUN mode when enabled and safe to route."""
|
||||
if not isHysteria2TUNEnabled():
|
||||
# Work on CoreManager's connection copy so a stored native TUN block
|
||||
# cannot run alongside the external tun2socks implementation.
|
||||
config.pop('tun', None)
|
||||
|
||||
return False
|
||||
|
||||
if PLATFORM == 'Linux' and not SystemRuntime.isAdmin():
|
||||
# Native Hysteria 2 creates the interface and routing table in its
|
||||
# own process, so it cannot use CoreManager's privileged helper.
|
||||
# Keep the existing external tun2socks path available instead.
|
||||
config.pop('tun', None)
|
||||
logger.warning(
|
||||
'Hysteria 2 native TUN requires superuser privileges on '
|
||||
'Linux; falling back to external tun2socks'
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
settings = getHysteria2TUNSettings()
|
||||
serverAddresses = resolveHysteria2ServerAddresses(config)
|
||||
route = settings.get('route', {})
|
||||
hasManualExclusions = bool(route.get('ipv4Exclude') or route.get('ipv6Exclude'))
|
||||
|
||||
if not serverAddresses and not hasManualExclusions:
|
||||
config.pop('tun', None)
|
||||
logger.error(
|
||||
'Hysteria 2 native TUN disabled for this connection because '
|
||||
'the server address could not be resolved and no manual route '
|
||||
'exclusion is configured'
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
config['tun'] = buildHysteria2TUNConfig(settings, serverAddresses)
|
||||
|
||||
return True
|
||||
|
||||
def startCore(
|
||||
self,
|
||||
config,
|
||||
@@ -133,6 +229,7 @@ class Hysteria2Plugin(FuriousPlugin):
|
||||
'disable_udp': False,
|
||||
}
|
||||
configcopy.pop('socks5', '')
|
||||
configcopy.pop('tun', None)
|
||||
|
||||
return configcopy
|
||||
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""Store and apply Hysteria 2 native TUN settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Frozenlib import *
|
||||
|
||||
import copy
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
|
||||
__all__ = [
|
||||
'DEFAULT_HYSTERIA2_TUN_SETTINGS',
|
||||
'buildHysteria2TUNConfig',
|
||||
'getHysteria2TUNSettings',
|
||||
'hasHysteria2TUNConfig',
|
||||
'isHysteria2TUNEnabled',
|
||||
'resolveHysteria2ServerAddresses',
|
||||
'saveHysteria2TUNSettings',
|
||||
'setHysteria2TUNEnabled',
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_HYSTERIA2_TUN_SETTINGS = {
|
||||
'name': 'utun777' if PLATFORM == 'Darwin' else 'hytun',
|
||||
'mtu': 1500,
|
||||
'timeout': '5m',
|
||||
'address': {
|
||||
'ipv4': '100.100.100.101/30',
|
||||
'ipv6': '2001::ffff:ffff:ffff:fff1/126',
|
||||
},
|
||||
'route': {
|
||||
'ipv4': ['0.0.0.0/0'],
|
||||
'ipv6': ['2000::/3'],
|
||||
'ipv4Exclude': [],
|
||||
'ipv6Exclude': [],
|
||||
},
|
||||
}
|
||||
|
||||
registerAppSettings('useHysteria2TUN', isBinary=True)
|
||||
registerAppSettings('Hysteria2TUNSettings')
|
||||
|
||||
|
||||
def isHysteria2TUNEnabled() -> bool:
|
||||
"""Return whether Hysteria 2 should provide TUN mode."""
|
||||
return AppSettings.isStateON_('useHysteria2TUN')
|
||||
|
||||
|
||||
def setHysteria2TUNEnabled(enabled: bool):
|
||||
"""Persist whether Hysteria 2 should provide TUN mode."""
|
||||
AppSettings.set(
|
||||
'useHysteria2TUN',
|
||||
AppBinarySettings.ON_ if enabled else AppBinarySettings.OFF,
|
||||
)
|
||||
|
||||
|
||||
def _normalizedStringList(value) -> list[str]:
|
||||
"""Return stripped, non-empty strings from a list-like value."""
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return []
|
||||
|
||||
return [item.strip() for item in value if isinstance(item, str) and item.strip()]
|
||||
|
||||
|
||||
def _normalizedHysteria2TUNSettings(settings) -> dict:
|
||||
"""Return validated Hysteria 2 TUN settings with safe defaults."""
|
||||
result = copy.deepcopy(DEFAULT_HYSTERIA2_TUN_SETTINGS)
|
||||
if not isinstance(settings, dict):
|
||||
return result
|
||||
|
||||
for key in ('name', 'timeout'):
|
||||
value = settings.get(key)
|
||||
if isinstance(value, str):
|
||||
result[key] = value.strip()
|
||||
|
||||
mtu = settings.get('mtu')
|
||||
if isinstance(mtu, int) and not isinstance(mtu, bool) and 1 <= mtu <= 65535:
|
||||
result['mtu'] = mtu
|
||||
|
||||
address = settings.get('address')
|
||||
if isinstance(address, dict):
|
||||
for key in ('ipv4', 'ipv6'):
|
||||
value = address.get(key)
|
||||
if isinstance(value, str):
|
||||
result['address'][key] = value.strip()
|
||||
|
||||
route = settings.get('route')
|
||||
if isinstance(route, dict):
|
||||
for key in ('ipv4', 'ipv6', 'ipv4Exclude', 'ipv6Exclude'):
|
||||
value = route.get(key)
|
||||
if isinstance(value, (list, tuple)):
|
||||
result['route'][key] = _normalizedStringList(value)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def getHysteria2TUNSettings() -> dict:
|
||||
"""Return a validated copy of the persisted Hysteria 2 TUN settings."""
|
||||
try:
|
||||
settings = json.loads(AppSettings.get('Hysteria2TUNSettings'))
|
||||
except Exception:
|
||||
settings = None
|
||||
|
||||
return _normalizedHysteria2TUNSettings(settings)
|
||||
|
||||
|
||||
def saveHysteria2TUNSettings(settings: dict):
|
||||
"""Validate and persist Hysteria 2 TUN settings."""
|
||||
AppSettings.set(
|
||||
'Hysteria2TUNSettings',
|
||||
json.dumps(_normalizedHysteria2TUNSettings(settings), ensure_ascii=False),
|
||||
)
|
||||
|
||||
|
||||
def _serverHost(config) -> str:
|
||||
"""Return the Hysteria server host from a config or share-style server URI."""
|
||||
server = config.get('server', '')
|
||||
if not isinstance(server, str) or not server.strip():
|
||||
return ''
|
||||
|
||||
try:
|
||||
host, _port = parseHostPort(server.strip())
|
||||
except Exception:
|
||||
host = None
|
||||
|
||||
if not host:
|
||||
try:
|
||||
host = config.itemAddress
|
||||
except Exception:
|
||||
host = server.rsplit(':', 1)[0]
|
||||
|
||||
if not isinstance(host, str):
|
||||
return ''
|
||||
|
||||
return host.strip().strip('[]')
|
||||
|
||||
|
||||
def resolveHysteria2ServerAddresses(config) -> list[str]:
|
||||
"""Resolve addresses that must bypass native TUN to prevent a route loop."""
|
||||
host = _serverHost(config)
|
||||
if not host:
|
||||
return []
|
||||
|
||||
unscopedHost = host.split('%', 1)[0]
|
||||
try:
|
||||
return [str(ipaddress.ip_address(unscopedHost))]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
addresses = []
|
||||
try:
|
||||
results = socket.getaddrinfo(
|
||||
host,
|
||||
0,
|
||||
family=socket.AF_UNSPEC,
|
||||
type=socket.SOCK_DGRAM,
|
||||
)
|
||||
except OSError as ex:
|
||||
logger.error(f'failed to resolve Hysteria 2 server {host!r}: {ex}')
|
||||
|
||||
return []
|
||||
|
||||
for family, _socketType, _protocol, _canonicalName, socketAddress in results:
|
||||
if family not in (socket.AF_INET, socket.AF_INET6) or not socketAddress:
|
||||
continue
|
||||
|
||||
address = str(socketAddress[0]).split('%', 1)[0]
|
||||
try:
|
||||
address = str(ipaddress.ip_address(address))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if address not in addresses:
|
||||
addresses.append(address)
|
||||
|
||||
return addresses
|
||||
|
||||
|
||||
def _hostPrefix(address: str):
|
||||
"""Return a host CIDR for one IPv4 or IPv6 address."""
|
||||
try:
|
||||
addressObject = ipaddress.ip_address(address.split('%', 1)[0])
|
||||
except ValueError:
|
||||
return None, None
|
||||
|
||||
key = 'ipv4Exclude' if addressObject.version == 4 else 'ipv6Exclude'
|
||||
|
||||
return key, f'{addressObject}/{addressObject.max_prefixlen}'
|
||||
|
||||
|
||||
def buildHysteria2TUNConfig(settings=None, serverAddresses=()) -> dict:
|
||||
"""Build a native TUN config and merge server route-loop exclusions."""
|
||||
settings = _normalizedHysteria2TUNSettings(
|
||||
getHysteria2TUNSettings() if settings is None else settings
|
||||
)
|
||||
|
||||
for address in serverAddresses:
|
||||
if not isinstance(address, str):
|
||||
continue
|
||||
|
||||
key, prefix = _hostPrefix(address)
|
||||
if key is not None and prefix not in settings['route'][key]:
|
||||
settings['route'][key].append(prefix)
|
||||
|
||||
result = {
|
||||
key: value
|
||||
for key, value in {
|
||||
'name': settings['name'],
|
||||
'mtu': settings['mtu'],
|
||||
'timeout': settings['timeout'],
|
||||
}.items()
|
||||
if value != ''
|
||||
}
|
||||
address = {key: value for key, value in settings['address'].items() if value != ''}
|
||||
route = {key: value for key, value in settings['route'].items() if value != []}
|
||||
|
||||
if address:
|
||||
result['address'] = address
|
||||
if route:
|
||||
result['route'] = route
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def hasHysteria2TUNConfig(config) -> bool:
|
||||
"""Return whether a Hysteria 2 configuration contains native TUN mode."""
|
||||
return isinstance(config.get('tun'), dict)
|
||||
Reference in New Issue
Block a user