From dea8b48a25bdf0c43f0fbb2a877e226115b69060 Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Mon, 3 Aug 2026 21:47:20 +0800 Subject: [PATCH] Add native Hysteria2 TUN support Signed-off-by: Loren Eteval --- Furious/Externals/GenTranslation.py | 27 ++ .../Official/Hysteria2/GuiTUNSettings.py | 290 ++++++++++++++++++ Furious/Plugins/Official/Hysteria2/Plugin.py | 99 +++++- Furious/Plugins/Official/Hysteria2/TUN.py | 246 +++++++++++++++ 4 files changed, 661 insertions(+), 1 deletion(-) create mode 100644 Furious/Plugins/Official/Hysteria2/GuiTUNSettings.py create mode 100644 Furious/Plugins/Official/Hysteria2/TUN.py diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index 36506e6..6265b07 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -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" } } diff --git a/Furious/Plugins/Official/Hysteria2/GuiTUNSettings.py b/Furious/Plugins/Official/Hysteria2/GuiTUNSettings.py new file mode 100644 index 0000000..110dd06 --- /dev/null +++ b/Furious/Plugins/Official/Hysteria2/GuiTUNSettings.py @@ -0,0 +1,290 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# 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 . + +"""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'' + _('TUN Documentation') + '') + + @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(), + ] diff --git a/Furious/Plugins/Official/Hysteria2/Plugin.py b/Furious/Plugins/Official/Hysteria2/Plugin.py index bda6417..45d471d 100644 --- a/Furious/Plugins/Official/Hysteria2/Plugin.py +++ b/Furious/Plugins/Official/Hysteria2/Plugin.py @@ -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 diff --git a/Furious/Plugins/Official/Hysteria2/TUN.py b/Furious/Plugins/Official/Hysteria2/TUN.py new file mode 100644 index 0000000..4e8717a --- /dev/null +++ b/Furious/Plugins/Official/Hysteria2/TUN.py @@ -0,0 +1,246 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# 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 . + +"""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)