Add native Hysteria2 TUN support

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-12 12:20:16 +08:00
parent f83f9dbe66
commit dea8b48a25
4 changed files with 661 additions and 1 deletions
+27
View File
@@ -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(),
]
+98 -1
View File
@@ -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
+246
View File
@@ -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)