Add native Xray TUN support

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-12 12:20:15 +08:00
parent c71ab94755
commit f97da38688
9 changed files with 607 additions and 4 deletions
+5 -1
View File
@@ -149,6 +149,10 @@ class CoreManager(Mixins.CleanupOnExit):
return False
pluginTUN = False
if not proxyModeOnly and SystemRuntime.isTUNMode():
pluginTUN = getPluginRegistry().prepareTUN(configcopy)
process, success = self._startCore(
configcopy,
routing,
@@ -171,7 +175,7 @@ class CoreManager(Mixins.CleanupOnExit):
return False
# TUN Mode handling
if not proxyModeOnly and SystemRuntime.isTUNMode():
if not proxyModeOnly and SystemRuntime.isTUNMode() and not pluginTUN:
if PLATFORM == 'Windows':
# cleanup first
SystemRoutingTable.delete('0.0.0.0', APPLICATION_TUN_GATEWAY_ADDRESS)
+128
View File
@@ -2154,5 +2154,133 @@ TRANSLATION = {
"RU": "Редактировать маршрут...",
"ZH": "编辑路由...",
"isReviewed": "True"
},
"Add VMess Server...": {
"source": [
"Furious.Plugins.Official.Xray.Plugin"
],
"RU": "Добавить сервер VMess...",
"ZH": "添加VMess服务器...",
"isReviewed": "True"
},
"Add VLESS Server...": {
"source": [
"Furious.Plugins.Official.Xray.Plugin"
],
"RU": "Добавить сервер VLESS...",
"ZH": "添加VLESS服务器...",
"isReviewed": "True"
},
"Add Shadowsocks Server...": {
"source": [
"Furious.Plugins.Official.Xray.Plugin"
],
"RU": "Добавить сервер Shadowsocks...",
"ZH": "添加Shadowsocks服务器...",
"isReviewed": "True"
},
"Add Trojan Server...": {
"source": [
"Furious.Plugins.Official.Xray.Plugin"
],
"RU": "Добавить сервер Trojan...",
"ZH": "添加Trojan服务器...",
"isReviewed": "True"
},
"Add Hysteria1 Server...": {
"source": [
"Furious.Plugins.Official.Hysteria1.Plugin"
],
"RU": "Добавить сервер Hysteria 1...",
"ZH": "添加Hysteria1服务器...",
"isReviewed": "True"
},
"Add Hysteria2 Server...": {
"source": [
"Furious.Plugins.Official.Hysteria2.Plugin"
],
"RU": "Добавить сервер Hysteria 2...",
"ZH": "添加Hysteria2服务器...",
"isReviewed": "True"
},
"Add SOCKS Server...": {
"source": [
"Furious.Plugins.Official.Xray.Plugin"
],
"RU": "Добавить сервер SOCKS...",
"ZH": "添加SOCKS服务器...",
"isReviewed": "True"
},
"Interface": {
"source": [
"Furious.Plugins.Official.Xray.GuiTUNSettings"
],
"RU": "Интерфейс",
"ZH": "接口",
"isReviewed": "True"
},
"Network": {
"source": [
"Furious.Plugins.Official.Xray.GuiTUNSettings"
],
"RU": "Сеть",
"ZH": "网络",
"isReviewed": "True"
},
"Use Xray-core TUN": {
"source": [
"Furious.Plugins.Official.Xray.Plugin"
],
"RU": "Использовать TUN Xray-core",
"ZH": "使用Xray-core TUN",
"isReviewed": "True"
},
"Customize Xray-core TUN Settings": {
"source": [
"Furious.Plugins.Official.Xray.GuiTUNSettings"
],
"RU": "Настроить параметры TUN Xray-core",
"ZH": "自定义Xray-core TUN设置",
"isReviewed": "True"
},
"Customize Xray-core TUN Settings...": {
"source": [
"Furious.Plugins.Official.Xray.Plugin"
],
"RU": "Настроить параметры TUN Xray-core...",
"ZH": "自定义Xray-core TUN设置...",
"isReviewed": "True"
},
"Gateway (separated by commas)": {
"source": [
"Furious.Plugins.Official.Xray.GuiTUNSettings"
],
"RU": "Gateway (через запятую)",
"ZH": "Gateway(以逗号分隔)",
"isReviewed": "True"
},
"DNS (separated by commas)": {
"source": [
"Furious.Plugins.Official.Xray.GuiTUNSettings"
],
"RU": "DNS (через запятую)",
"ZH": "DNS(以逗号分隔)",
"isReviewed": "True"
},
"AutoSystemRoutingTable (separated by commas)": {
"source": [
"Furious.Plugins.Official.Xray.GuiTUNSettings"
],
"RU": "AutoSystemRoutingTable (через запятую)",
"ZH": "AutoSystemRoutingTable(以逗号分隔)",
"isReviewed": "True"
},
"TUN Documentation": {
"source": [
"Furious.Plugins.Official.Xray.GuiTUNSettings"
],
"RU": "Документация TUN",
"ZH": "TUN文档",
"isReviewed": "True"
}
}
+4
View File
@@ -69,6 +69,10 @@ class FuriousPlugin:
"""Return optional actions for this plugin's management submenu."""
return tuple()
def prepareTUN(self, config) -> bool:
"""Prepare plugin-native TUN and return whether the plugin handles it."""
return False
def routingOptions(self, config=None):
"""Return routing modes supported for a plugin configuration."""
return tuple()
@@ -3,6 +3,7 @@
from __future__ import annotations
from Furious.Frozenlib import *
from Furious.Qt.DynamicTranslate import gettext as _
from Furious.Plugins.API import FuriousPlugin, PluginProtocol, PluginRouting
from Furious.Plugins.Official.Configuration import (
BLANK_CONFIG_HYSTERIA1,
@@ -18,6 +19,10 @@ __all__ = ['Hysteria1Plugin']
logger = logging.getLogger(__name__)
_TRANSLATABLE_ACTION_TEXT = [
_('Add Hysteria1 Server...'),
]
class Hysteria1Plugin(FuriousPlugin):
"""Provide official Hysteria 1 support."""
@@ -2,6 +2,7 @@
from __future__ import annotations
from Furious.Qt.DynamicTranslate import gettext as _
from Furious.Plugins.API import FuriousPlugin, PluginProtocol
from Furious.Plugins.Official.Configuration import (
BLANK_CONFIG_HYSTERIA2,
@@ -17,6 +18,10 @@ __all__ = ['Hysteria2Plugin']
logger = logging.getLogger(__name__)
_TRANSLATABLE_ACTION_TEXT = [
_('Add Hysteria2 Server...'),
]
class Hysteria2Plugin(FuriousPlugin):
"""Provide official Hysteria 2 support."""
@@ -0,0 +1,242 @@
"""Provide the editor for Xray-core native TUN settings."""
from __future__ import annotations
from Furious.Frozenlib import *
from Furious.Qt import *
from Furious.Qt import gettext as _
from PySide6.QtWidgets import *
import copy
import functools
import logging
import re
from .TUN import getXrayTUNSettings, saveXrayTUNSettings
__all__ = ['GuiXrayTUNSettings']
logger = logging.getLogger(__name__)
class GuiXrayTUNItemText(GuiEditorItemTextInput):
"""Edit one string-valued Xray TUN setting."""
def __init__(self, *args, **kwargs):
"""Initialize an Xray TUN string editor."""
self.key = kwargs.pop('key')
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 an Xray TUN settings mapping."""
value = self.text().strip()
if config.get(self.key, '') == value:
return False
config[self.key] = value
return True
def factoryToInput(self, config: dict):
"""Load one Xray TUN string setting into the editor."""
self.setText(config.get(self.key, ''))
class GuiXrayTUNItemList(GuiXrayTUNItemText):
"""Edit one list-valued Xray TUN setting as comma-separated text."""
def inputToFactory(self, config: dict) -> bool:
"""Store comma- or line-separated values in the settings mapping."""
value = [
item.strip() for item in re.split(r'[,\n]', self.text()) if item.strip()
]
if config.get(self.key, []) == value:
return False
config[self.key] = value
return True
def factoryToInput(self, config: dict):
"""Load one Xray TUN list setting into the editor."""
value = config.get(self.key, [])
self.setText(','.join(value) if isinstance(value, list) else '')
class GuiXrayTUNItemNumber(GuiEditorItemTextSpinBox):
"""Edit one integer-valued Xray TUN setting."""
def __init__(self, *args, **kwargs):
"""Initialize an Xray TUN numeric editor."""
self.key = kwargs.pop('key')
self.default = kwargs.pop('default')
minimum = kwargs.pop('minimum')
maximum = kwargs.pop('maximum')
super().__init__(*args, **kwargs)
self.setRange(minimum, maximum)
def inputToFactory(self, config: dict) -> bool:
"""Store the current number in an Xray TUN settings mapping."""
value = self.value()
if config.get(self.key, self.default) == value:
return False
config[self.key] = value
return True
def factoryToInput(self, config: dict):
"""Load one Xray TUN numeric setting into the editor."""
value = config.get(self.key, self.default)
self.setValue(value if isinstance(value, int) else self.default)
class GuiXrayTUNSettingsGroupBoxInterface(GuiEditorWidgetQGroupBox):
"""Edit Xray TUN interface identity and packet 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 [
GuiXrayTUNItemText(
title='Name',
translatable=False,
key='name',
maximumWidth=360,
),
GuiXrayTUNItemText(
title='Description',
translatable=False,
key='desc',
maximumWidth=360,
),
GuiXrayTUNItemNumber(
title='MTU',
key='mtu',
default=1500,
minimum=1,
maximum=65535,
),
GuiXrayTUNItemNumber(
title='UserLevel',
key='userLevel',
default=0,
minimum=0,
maximum=65535,
),
]
class XrayTUNDocumentationURL(AppQLabel):
"""Link to the official Xray-core TUN documentation."""
URL = 'https://xtls.github.io/config/inbounds/tun.html'
def __init__(self, *args, **kwargs):
"""Initialize the Xray 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(
'<html><head/><body><p>'
f'<a href="{self.URL}">'
'<span style=" text-decoration: underline; color:#007ad6;">'
+ _('TUN Documentation')
+ '</span></a></p></body></html>'
)
@staticmethod
def handleLinkActivated(link: str):
"""Open the Xray TUN documentation in the default browser."""
if QDesktopServices.openUrl(QtCore.QUrl(link)):
logger.info(f'open link \'{link}\' success')
else:
logger.error(f'open link \'{link}\' failed')
def retranslate(self):
"""Refresh the translated documentation hyperlink text."""
self.setWebsiteURL()
class GuiXrayTUNSettingsGroupBoxNetwork(GuiEditorWidgetQGroupBox):
"""Edit Xray TUN addresses, DNS, and automatic routing settings."""
def __init__(self, **kwargs):
"""Initialize the network settings group."""
super().__init__(_('Network'), **kwargs)
def containerSequence(self):
"""Return network-setting editors in display order."""
return [
GuiXrayTUNItemList(
title=_('Gateway (separated by commas)'),
key='gateway',
),
GuiXrayTUNItemList(
title=_('DNS (separated by commas)'),
key='dns',
),
GuiXrayTUNItemList(
title=_('AutoSystemRoutingTable (separated by commas)'),
key='autoSystemRoutingTable',
),
GuiXrayTUNItemText(
title='AutoOutboundsInterface',
translatable=False,
key='autoOutboundsInterface',
),
]
class GuiXrayTUNSettings(GuiEditorWidgetQDialog):
"""Edit and persist Xray-core native TUN settings."""
def __init__(self, *args, **kwargs):
"""Initialize the Xray-core TUN settings dialog."""
kwargs.setdefault('style', 'portrait')
kwargs.setdefault('tabTranslatable', True)
super().__init__(*args, **kwargs)
self.setTabText(_('Customize Xray-core TUN Settings'))
self.setFixedSize(int(650 * GOLDEN_RATIO), 650)
self._settings = getXrayTUNSettings()
self.factoryToInput(self._settings)
self.accepted.connect(self.handleAccepted)
self.layout().takeRow(self.layout().rowCount() - 1)
bottomLayout = QHBoxLayout()
bottomLayout.addWidget(XrayTUNDocumentationURL())
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
saveXrayTUNSettings(self._settings)
if SystemRuntime.isTUNMode():
showMBoxNewChangesNextTime()
@functools.lru_cache(None)
def groupBoxSequence(self):
"""Return Xray TUN setting groups in display order."""
return [
GuiXrayTUNSettingsGroupBoxInterface(),
GuiXrayTUNSettingsGroupBoxNetwork(),
]
+71 -3
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from Furious.Core.CoreProcessWorker import ProcessOutputRedirector
from Furious.Frozenlib import *
from Furious.Qt.DynamicTranslate import gettext as _
from Furious.Library.Storage import Storage
from Furious.Plugins.API import FuriousPlugin, PluginProtocol, PluginRouting
from Furious.Plugins.Official.Configuration import (
@@ -19,6 +20,12 @@ import uuid
from .Core import XrayCore
from .Routing import customRoutingObjectFromSettings
from .TUN import (
buildXrayTUNInbound,
hasXrayTUNInbound,
isXrayTUNEnabled,
setXrayTUNEnabled,
)
__all__ = ['XrayPlugin']
@@ -63,6 +70,15 @@ def fixLogObjectPath(config, attr: str, value: str, log=True):
)
_TRANSLATABLE_ACTION_TEXT = [
_('Add VMess Server...'),
_('Add VLESS Server...'),
_('Add Shadowsocks Server...'),
_('Add Trojan Server...'),
_('Add SOCKS Server...'),
]
class XrayPlugin(FuriousPlugin):
"""Provide official Xray-core support."""
@@ -159,17 +175,38 @@ class XrayPlugin(FuriousPlugin):
return self.createEditorForProtocol(config.proxyProtocol, parent, **kwargs)
def createManagementActions(self, parent=None, **kwargs):
"""Create Xray routing and asset-management actions."""
"""Create Xray routing, TUN, and asset-management actions."""
# These modules require a fully initialized Furious.Qt package.
from Furious.Qt import AppQAction, AppQSeperator, bootstrapIcon
from Furious.Qt import (
AppQAction,
AppQSeperator,
bootstrapIcon,
showMBoxNewChangesNextTime,
)
from Furious.Qt import gettext as _
from .GuiTUNSettings import GuiXrayTUNSettings
from .UserRoutingWindow import UserRoutingWindow
from .XrayAssetViewerWindow import XrayAssetViewerWindow
routingEditor = UserRoutingWindow(parent=parent, **kwargs)
assetViewer = XrayAssetViewerWindow(parent=parent)
useXrayTUNAction = AppQAction(
_('Use Xray-core TUN'),
checkable=True,
checked=isXrayTUNEnabled(),
)
def updateUseXrayTUN():
"""Persist the native Xray TUN action state."""
setXrayTUNEnabled(useXrayTUNAction.isChecked())
if SystemRuntime.isTUNMode():
showMBoxNewChangesNextTime()
useXrayTUNAction.callback = updateUseXrayTUN
return (
AppQAction(
_('Edit Routing...'),
@@ -177,12 +214,39 @@ class XrayPlugin(FuriousPlugin):
callback=routingEditor.show,
),
AppQSeperator(),
useXrayTUNAction,
AppQAction(
_('Customize Xray-core TUN Settings...'),
callback=lambda: GuiXrayTUNSettings(parent=parent).open(),
),
AppQSeperator(),
AppQAction(
_('Manage Xray-core Asset File...'),
callback=assetViewer.show,
),
)
def prepareTUN(self, config) -> bool:
"""Add the configured Xray native TUN inbound when enabled."""
if not isXrayTUNEnabled():
return False
inbounds = config.get('inbounds')
if not isinstance(inbounds, list):
inbounds = []
config['inbounds'] = [
inbound
for inbound in inbounds
if not (
isinstance(inbound, dict)
and str(inbound.get('protocol', '')).casefold() == 'tun'
)
]
config['inbounds'].append(buildXrayTUNInbound())
return True
def routingOptions(self, config=None):
"""Return built-in and named routing modes supported by Xray."""
options = [
@@ -241,7 +305,11 @@ class XrayPlugin(FuriousPlugin):
fixLogObjectPath(config, attr, logRedirectValue, log)
if routing == AppBuiltinRouting.BypassMainlandChina.value:
if not proxyModeOnly and SystemRuntime.isTUNMode():
if (
not proxyModeOnly
and SystemRuntime.isTUNMode()
and not hasXrayTUNInbound(config)
):
# Defer Qt access until the application has finished importing it.
from Furious.Qt.QtWidgets import showMBoxDirectRulesNotAllowed
+130
View File
@@ -0,0 +1,130 @@
"""Store and apply Xray-core native TUN settings."""
from __future__ import annotations
from Furious.Frozenlib import *
import copy
import json
__all__ = [
'DEFAULT_XRAY_TUN_SETTINGS',
'buildXrayTUNInbound',
'getXrayTUNSettings',
'hasXrayTUNInbound',
'isXrayTUNEnabled',
'saveXrayTUNSettings',
'setXrayTUNEnabled',
]
DEFAULT_XRAY_TUN_SETTINGS = {
'name': '',
'desc': '',
'mtu': 1500,
'gateway': ['10.0.0.1/16', 'fc00::1/64'],
'dns': ['1.1.1.1', '8.8.8.8'],
'userLevel': 0,
'autoSystemRoutingTable': ['0.0.0.0/0', '::/0'],
'autoOutboundsInterface': 'auto',
}
registerAppSettings('useXrayTUN', isBinary=True)
registerAppSettings('XrayTUNSettings')
def isXrayTUNEnabled() -> bool:
"""Return whether Xray-core should provide TUN mode."""
return AppSettings.isStateON_('useXrayTUN')
def setXrayTUNEnabled(enabled: bool):
"""Persist whether Xray-core should provide TUN mode."""
AppSettings.set(
'useXrayTUN',
AppBinarySettings.ON_ if enabled else AppBinarySettings.OFF,
)
def _normalizedXrayTUNSettings(settings) -> dict:
"""Return validated Xray TUN settings with defaults for invalid fields."""
result = copy.deepcopy(DEFAULT_XRAY_TUN_SETTINGS)
if not isinstance(settings, dict):
return result
for key in ('name', 'desc', 'autoOutboundsInterface'):
value = settings.get(key)
if isinstance(value, str):
result[key] = value.strip()
for key in ('gateway', 'dns', 'autoSystemRoutingTable'):
value = settings.get(key)
if isinstance(value, (list, tuple)):
result[key] = [
item.strip() for item in value if isinstance(item, str) and item.strip()
]
mtu = settings.get('mtu')
if isinstance(mtu, int) and not isinstance(mtu, bool) and mtu > 0:
result['mtu'] = mtu
userLevel = settings.get('userLevel')
if (
isinstance(userLevel, int)
and not isinstance(userLevel, bool)
and userLevel >= 0
):
result['userLevel'] = userLevel
return result
def getXrayTUNSettings() -> dict:
"""Return a validated copy of the persisted Xray TUN settings."""
try:
settings = json.loads(AppSettings.get('XrayTUNSettings'))
except Exception:
settings = None
return _normalizedXrayTUNSettings(settings)
def saveXrayTUNSettings(settings: dict):
"""Validate and persist Xray TUN settings."""
AppSettings.set(
'XrayTUNSettings',
json.dumps(_normalizedXrayTUNSettings(settings), ensure_ascii=False),
)
def buildXrayTUNInbound(settings=None) -> dict:
"""Build an Xray TUN inbound from persisted or supplied settings."""
settings = _normalizedXrayTUNSettings(
getXrayTUNSettings() if settings is None else settings
)
settings = {
key: value for key, value in settings.items() if value != '' and value != []
}
return {
'tag': 'tun',
'protocol': 'tun',
'settings': settings,
'sniffing': {
'enabled': True,
'destOverride': ['http', 'tls', 'quic'],
},
}
def hasXrayTUNInbound(config) -> bool:
"""Return whether an Xray configuration contains a TUN inbound."""
inbounds = config.get('inbounds', [])
if not isinstance(inbounds, list):
return False
return any(
isinstance(inbound, dict)
and str(inbound.get('protocol', '')).casefold() == 'tun'
for inbound in inbounds
)
+17
View File
@@ -205,6 +205,23 @@ class PluginRegistry:
return tuple()
def prepareTUN(self, config) -> bool:
"""Ask a configuration's plugin to prepare its native TUN support."""
plugin = self.pluginForConfig(config)
if plugin is None:
return False
try:
handled = plugin.prepareTUN(config)
if not isinstance(handled, bool):
raise TypeError('plugin TUN preparation result must be a boolean')
return handled
except Exception as ex:
logger.error(f'TUN preparation failed for {plugin.pluginId!r}: {ex}')
return False
def routingOptions(self, config):
"""Return validated routing modes supported by a configuration's plugin."""
plugin = self.pluginForConfig(config)