Integrate sing-tun application TUN backend

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-10-06 00:37:17 +08:00
parent 714fad2d4b
commit 0dc11b6755
26 changed files with 2342 additions and 101 deletions
@@ -442,6 +442,7 @@ class DesktopApplication(ApplicationRunner, SingletonApplication):
self._userServers = None
self._userSubs = None
self._userTUNSettings = None
self._userSingTUNSettings = None
# ThreadPool
self.threadPool = QtCore.QThreadPool(self)
@@ -624,6 +625,7 @@ class DesktopApplication(ApplicationRunner, SingletonApplication):
self._userServers = Storage.UserServers()
self._userSubs = Storage.UserSubs()
self._userTUNSettings = Storage.UserTUNSettings()
self._userSingTUNSettings = Storage.UserSingTUNSettings()
def _initializeControllers(self):
"""Create the process-lifetime application state authorities."""
+96
View File
@@ -34,6 +34,7 @@ from Furious.Backends.SocksURI import (
parseSocksURI,
serializeSocksURI,
)
from Furious.Frozenlib.SocksProxy import socksURL
from typing import Union, Tuple
@@ -1575,6 +1576,49 @@ class ConfigXray(CoreConfiguration):
return ''
def applicationTUNProxy(self) -> str:
"""Validate Xray's selected listener and carry its optional SOCKS auth."""
endpoint = socksURL(self.socksProxy())
for inbound in self.get('inbounds', []):
if inbound.get('protocol') != 'socks':
continue
settings = inbound.get('settings', {})
if settings.get('udp', False) is not True:
raise ValueError(
'Application TUN requires SOCKS UDP on the selected Xray listener'
)
auth = settings.get('auth', 'noauth')
if auth == 'noauth':
return endpoint
if auth != 'password':
raise ValueError('Unsupported Xray SOCKS authentication')
accounts = settings.get('accounts', [])
if not accounts or not isinstance(accounts[0], dict):
raise ValueError('Xray SOCKS authentication requires an account')
user, password = accounts[0].get('user', ''), accounts[0].get('pass', '')
if not isinstance(user, str) or not isinstance(password, str):
raise ValueError('Invalid Xray SOCKS authentication')
return socksURL(
'socks5://'
+ quote(user, safe='')
+ ':'
+ quote(password, safe='')
+ '@'
+ endpoint.removeprefix('socks5://')
)
raise ValueError('A SOCKS listener is required for application TUN')
def setHttpProxy(self, endpoint: str) -> bool:
"""Set the local HTTP proxy endpoint."""
try:
@@ -1925,6 +1969,32 @@ class ConfigHysteria1(CoreConfiguration):
return ''
def applicationTUNProxy(self) -> str:
"""Validate the selected Hysteria SOCKS transit listener."""
listener = self.get('socks5', {})
if listener.get('disable_udp', False) is not False:
raise ValueError('Application TUN requires SOCKS UDP forwarding')
endpoint = socksURL(self.socksProxy())
user, password = listener.get('user', ''), listener.get('password', '')
if not user and not password:
return endpoint
if not isinstance(user, str) or not isinstance(password, str):
raise ValueError('Invalid SOCKS authentication')
return socksURL(
'socks5://'
+ quote(user, safe='')
+ ':'
+ quote(password, safe='')
+ '@'
+ endpoint.removeprefix('socks5://')
)
def setHttpProxy(self, endpoint: str) -> bool:
"""Set the local HTTP proxy endpoint."""
try:
@@ -2285,6 +2355,32 @@ class ConfigHysteria2(CoreConfiguration):
return ''
def applicationTUNProxy(self) -> str:
"""Validate the selected Hysteria SOCKS transit listener."""
listener = self.get('socks5', {})
if listener.get('disableUDP', False) is not False:
raise ValueError('Application TUN requires SOCKS UDP forwarding')
endpoint = socksURL(self.socksProxy())
user, password = listener.get('username', ''), listener.get('password', '')
if not user and not password:
return endpoint
if not isinstance(user, str) or not isinstance(password, str):
raise ValueError('Invalid SOCKS authentication')
return socksURL(
'socks5://'
+ quote(user, safe='')
+ ':'
+ quote(password, safe='')
+ '@'
+ endpoint.removeprefix('socks5://')
)
def setHttpProxy(self, endpoint: str) -> bool:
"""Set the local HTTP proxy endpoint."""
try:
+21
View File
@@ -50,6 +50,12 @@ SYSTEM_PROXY_MODE_OPTIONS = (
)
registerAppSettings('VPNMode', isBinary=True)
# A missing preference, including upgrades from 0.8.2, deliberately selects
# sing-tun. VPNMode remains independent: choosing an engine does not enable TUN.
# 0.8.2 ignores this key and continues using application-managed tun2socks.
registerAppSettings(
APPLICATION_TUN_BACKEND, validRange=list(TUN_BACKENDS), default='sing-tun'
)
registerAppSettings(
APPLICATION_THEME_SETTING,
validRange=[theme.value for theme in ApplicationTheme],
@@ -145,6 +151,7 @@ class SettingsController(QtCore.QObject):
"""Apply application settings independently from their presentation."""
tunModeChanged = QtCore.Signal(bool)
tunBackendChanged = QtCore.Signal(str)
systemProxyModeChanged = QtCore.Signal(str)
def __init__(self, parent=None):
@@ -184,6 +191,20 @@ class SettingsController(QtCore.QObject):
showMBoxNewChangesNextTime()
def setTUNBackend(self, backend: str):
"""Select the application TUN implementation for the next connection."""
if backend not in TUN_BACKENDS:
raise ValueError('Unknown application TUN backend')
if AppSettings.get(APPLICATION_TUN_BACKEND) == backend:
return
AppSettings.set(APPLICATION_TUN_BACKEND, backend)
self.tunBackendChanged.emit(backend)
showMBoxNewChangesNextTime()
@staticmethod
def setApplicationTheme(theme: ApplicationTheme | str):
"""Persist and immediately apply one application theme preference."""
+9 -2
View File
@@ -1,10 +1,10 @@
# Embedded runtime guidance
Inherit `Furious/AGENTS.md` and its root ancestor. Consult Interface for runtime contracts and Service for
connection ownership. This scope owns reusable embedded execution machinery and application tun2socks;
connection ownership. This scope owns reusable embedded execution machinery and application TUN engines;
connection policy remains outside it.
- `Core` supplies shared multiprocessing runtime machinery, bounded output transport, and application tun2socks. External
- `Core` supplies shared multiprocessing runtime machinery, bounded output transport, and application tun2socks/sing-tun. External
Core owns its separate direct `subprocess.Popen`; neither layer owns controller, repository, UI, or protocol policy.
- A launch spec describes prepared child construction, never semantic connection readiness. Serialization and launch
arguments are prepared before execution starts; constructors may create owned timers/queues that still need
@@ -42,3 +42,10 @@ connection policy remains outside it.
queues, or callbacks. Start with `tests/test_runtime_lifecycle.py` and `tests/test_connection_startup_async.py`;
output/process stress lives in the tiers documented by `tests/README.md`. Review output admission and draining
together when changing backpressure.
- `SingTUN` imports the Go binding only in a spawned child. Its bounded status pipe establishes native readiness
and the actual device name independently of diagnostic output and process liveness. Cooperative stop is followed
by exact-child reap and attempt-local host recovery; retain the lease when either fails. Never infer Go cleanup
from forced termination. Its `SingTUNHostPlan` remains attached through host-worker drain and restoration; tests
in `tests/test_sing_tun.py` cover startup cancellation, validated status with binding-provided failure reasons,
and cleanup refusal/retry. Privileged
platform smoke tests remain separate from harmless source/compiled lifetime checks.
+11 -3
View File
@@ -76,7 +76,9 @@ class MultiprocessingRuntime(CoreRuntime):
StopJoinTimeout = 3
def __init__(self, launchFactory, *, exitCallback=None, msgCallback=None):
def __init__(
self, launchFactory, *, exitCallback=None, msgCallback=None, processContext=None
):
"""Prepare an immutable launch specification around an owned queue."""
super().__init__(exitCallback)
@@ -84,7 +86,8 @@ class MultiprocessingRuntime(CoreRuntime):
self._lastExit = None
self._stopRequested = False
self._exitPublished = False
self._output = MsgQueue(msgCallback=msgCallback)
self._processContext = processContext
self._output = MsgQueue(msgCallback=msgCallback, context=processContext)
try:
launch = launchFactory(self._output)
@@ -144,7 +147,12 @@ class MultiprocessingRuntime(CoreRuntime):
self.setState(RuntimeState.Starting)
try:
process = multiprocessing.Process(**self._launch.processKeywordArguments())
if self._processContext is not None:
factory = self._processContext.Process
else:
factory = multiprocessing.Process
process = factory(**self._launch.processKeywordArguments())
self._process = process
+6 -7
View File
@@ -52,15 +52,14 @@ class MsgQueue(multiprocessing.queues.Queue):
def __init__(self, **kwargs):
"""Initialize the bounded queue and its GUI-thread drain timer."""
callback = kwargs.pop('msgCallback', None)
maximum = kwargs.pop('maximumPendingMessages', self.MAXIMUM_PENDING_MESSAGES)
super().__init__(
maxsize=maximum,
**kwargs,
ctx=multiprocessing.get_context(),
callback, maximum, context = (
kwargs.pop('msgCallback', None),
kwargs.pop('maximumPendingMessages', self.MAXIMUM_PENDING_MESSAGES),
kwargs.pop('context', None) or multiprocessing.get_context(),
)
super().__init__(maxsize=maximum, **kwargs, ctx=context)
self.timer = QtCore.QTimer()
self._timerConnection = connectWeakly(
self.timer.timeout,
+373
View File
@@ -0,0 +1,373 @@
# 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/>.
"""Spawn-only sing-tun bridge with authoritative readiness and cooperative stop."""
from __future__ import annotations
from Furious.Core.MultiprocessingRuntime import (
MultiprocessingRuntime,
ProcessLaunchSpec,
)
from Furious.Core.ProcessOutput import ProcessOutputRedirector
from Furious.Interface import RuntimeExit, RuntimeExitReason, RuntimeStartError
import copy
import importlib.metadata
import json
import multiprocessing
import threading
def _nativeError(engine):
try:
error = engine.status.get('error', '')
except (AttributeError, RuntimeError):
return ''
return error if isinstance(error, str) else ''
def _runEngine(config, control, engineFactory=None):
"""Child control remains responsive while native startup runs on its own thread."""
engine = None
worker = None
complete = threading.Event()
errors = []
requested = False
try:
if engineFactory is None:
binding = importlib.import_module('sing_tun')
engine = binding.Engine(binding.Config(**config))
else:
engine = engineFactory(config)
def initialize():
try:
engine.start()
except BaseException as ex:
errors.append(ex)
finally:
complete.set()
worker = threading.Thread(target=initialize, name='sing-tun-initialize')
worker.start()
announced = False
while True:
if control.poll(0.05):
try:
command = control.recv_bytes(64)
except (EOFError, OSError):
command = b'stop'
if command == b'stop':
requested = True
engine.stop()
if not complete.is_set():
continue
if errors and not requested:
raise errors[0]
if not announced and not requested:
if not engine.ready:
raise RuntimeError('sing-tun did not become ready')
control.send_bytes(
json.dumps(
{'state': 'ready', 'device_name': engine.device_name}
).encode()
)
announced = True
if engine.wait(0):
break
worker.join()
engine.close(5)
try:
control.send_bytes(b'{"state":"stopped"}')
except (EOFError, OSError):
pass
except BaseException as ex:
if isinstance(ex, ImportError):
message = 'sing-tun package is unavailable or incompatible'
else:
message = str(ex) or 'sing-tun native startup or cleanup failed'
nativeMessage = _nativeError(engine)
if nativeMessage:
message = 'sing-tun: ' + nativeMessage
try:
control.send_bytes(
json.dumps({'state': 'failed', 'error': message}).encode()
)
except (OSError, EOFError):
pass
raise RuntimeError(message) from None
finally:
try:
if engine is not None:
try:
engine.stop()
except BaseException:
pass
if worker is not None:
worker.join(5)
try:
engine.close(5)
except BaseException:
# The authoritative failure was already published above.
# Preserve that failure if cleanup also fails; the parent
# retains recovery for incomplete cleanup.
pass
finally:
control.close()
def startSingTUN(output, config, control):
"""Import the Go binding only inside the spawned child."""
ProcessOutputRedirector.launch(output, lambda: _runEngine(config, control), True)
class SingTUN(MultiprocessingRuntime):
"""Own IPC, child execution, and registered attempt-local host restoration."""
CooperativeStopTimeout = 6
def __init__(self, config, *, hostPlan=None, **kwargs):
context = multiprocessing.get_context('spawn')
self._control, self._childControl = context.Pipe()
self._status = {'state': 'created'}
self._hostPlan = hostPlan
self._started = False
self._cleanExit = False
self._nativeWasReady = False
self.cleanup = None
self._configuration = copy.deepcopy(config)
try:
super().__init__(
lambda output: ProcessLaunchSpec(
target=startSingTUN,
args=(output, copy.deepcopy(config), self._childControl),
),
processContext=context,
**kwargs,
)
except BaseException:
self._control.close()
self._childControl.close()
raise
@staticmethod
def name():
return 'sing-tun'
@staticmethod
def version():
try:
return importlib.metadata.version('sing-tun')
except importlib.metadata.PackageNotFoundError:
return 'unavailable'
def start(self):
if self._started:
raise RuntimeStartError('sing-tun runtimes are one-shot')
self._started = True
try:
if self._hostPlan is not None:
configuration = self._hostPlan.configuration
else:
configuration = self._configuration
self._launch = ProcessLaunchSpec(
target=startSingTUN,
args=(self._output, copy.deepcopy(configuration), self._childControl),
)
if self._hostPlan is not None:
self._hostPlan.markActivated()
super().start()
self._monitor.setInterval(50)
finally:
self._childControl.close()
def _readStatus(self):
try:
for _ in range(8):
if self._control.closed or not self._control.poll():
break
status = json.loads(self._control.recv_bytes(4096))
if not isinstance(status, dict) or status.get('state') not in (
'ready',
'failed',
'stopped',
):
raise ValueError()
if status['state'] == 'ready':
name = status.get('device_name')
if (
not isinstance(name, str)
or not 1 <= len(name) <= 32
or not all(
character.isascii()
and (character.isalnum() or character in '_.-')
for character in name
)
):
raise ValueError()
self._nativeWasReady = True
elif status['state'] == 'failed':
error = status.get('error')
if not isinstance(error, str) or not error:
raise ValueError()
if self._status.get('state') == 'failed':
continue
self._status = {**self._status, **status}
self._cleanExit = status['state'] == 'stopped'
except EOFError:
pass
except (OSError, ValueError, TypeError) as ex:
if isinstance(ex, OSError) and str(ex) != 'bad message length':
# Windows can report a normal peer close as a broken pipe.
return
self._status = {
'state': 'failed',
'error': 'Invalid sing-tun status message',
}
@property
def ready(self):
self._readStatus()
return self._status.get('state') == 'ready' and self.isRunning()
@property
def deviceName(self):
self._readStatus()
return self._status.get('device_name', '')
@property
def startupError(self):
self._readStatus()
return self._status.get('error', '')
def _pollProcess(self):
self._readStatus()
super()._pollProcess()
def interpretExit(self, exitcode, *, requested=False):
self._readStatus()
if self.startupError:
if self._nativeWasReady:
reason = RuntimeExitReason.Unexpected
else:
reason = RuntimeExitReason.StartFailure
return RuntimeExit(exitcode, reason, message=self.startupError)
return super().interpretExit(exitcode, requested=requested)
def stop(self):
if self._hostPlan is not None:
self._hostPlan.drain()
process = self._process
if process is not None and process.is_alive():
self._stopRequested = True
try:
self._control.send_bytes(b'stop')
except (OSError, EOFError):
pass
# Native close has a five-second deadline; allow it to complete
# before falling back to the shared exact-child escalation path.
process.join(self.CooperativeStopTimeout)
self._readStatus()
errors = []
try:
super().stop()
except Exception as ex:
errors.append(str(ex))
# A stopped process is not proof of host restoration. Failure keeps the
# lease retryable, even after its process handle has been released.
if self._hostPlan is not None:
try:
if self._process is None or not self._process.is_alive():
self._hostPlan.finishCleanup(self._cleanExit or not self._started)
else:
# A retained live child prevents native recovery, but host
# DNS snapshots can be restored independently.
self._hostPlan.finishDNSCleanup()
except Exception as ex:
errors.append(str(ex))
if callable(self.cleanup):
try:
self.cleanup()
except Exception as ex:
errors.append(str(ex))
if errors:
raise RuntimeError('; '.join(errors))
def dispose(self):
super().dispose()
self._control.close()
self._childControl.close()
+2
View File
@@ -22,6 +22,7 @@ from __future__ import annotations
from .MultiprocessingRuntime import MultiprocessingRuntime, ProcessLaunchSpec
from .ProcessOutput import MsgQueue, ProcessOutputRedirector
from .Tun2socks import Tun2socks
from .SingTUN import SingTUN
__all__ = [
'MultiprocessingRuntime',
@@ -29,4 +30,5 @@ __all__ = [
'ProcessLaunchSpec',
'ProcessOutputRedirector',
'Tun2socks',
'SingTUN',
]
+5
View File
@@ -44,6 +44,7 @@ __all__ = [
'APPLICATION_TUN2SOCKS_IP_ADDRESS',
'APPLICATION_TUN2SOCKS_INTERFACE_DNS_ADDRESS',
'APPLICATION_TUN2SOCKS_NETWORK_INTERFACE_NAME',
'APPLICATION_TUN_BACKEND',
'APPLICATION_VERSION',
'CORE_CHECK_ALIVE_INTERVAL',
'CRASH_LOG_DIR',
@@ -65,6 +66,7 @@ __all__ = [
'PYSIDE6_VERSION',
'ROOT_DIR',
'SYSTEM_LANGUAGE',
'TUN_BACKENDS',
'URL_GEOIP',
'URL_GEOIP_SHA256',
'URL_GEOSITE',
@@ -138,6 +140,9 @@ if PLATFORM == 'Windows':
CORE_CHECK_ALIVE_INTERVAL = 2500
APPLICATION_TUN_BACKEND = 'ApplicationTUNBackend'
TUN_BACKENDS = ('sing-tun', 'tun2socks')
if PLATFORM == 'Windows':
APPLICATION_TUN2SOCKS_DEVICE_NAME = APPLICATION_NAME
elif PLATFORM == 'Darwin':
+95
View File
@@ -0,0 +1,95 @@
# 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 local SOCKS endpoint validation and normalization helpers."""
from __future__ import annotations
import ipaddress
import urllib.parse
def socksURL(endpoint):
"""Normalize a local listener without permitting direct DNS or logging secrets."""
if (
not isinstance(endpoint, str)
or not endpoint
or any(c in endpoint for c in '\x00\r\n')
):
raise ValueError('A local SOCKS endpoint is required')
# Xray's legacy accessor can produce an unbracketed IPv6 host:port.
if (
'://' not in endpoint
and not endpoint.startswith('[')
and endpoint.count(':') > 1
):
host, port = endpoint.rsplit(':', 1)
endpoint = '[' + host + ']:' + port
try:
parsed = urllib.parse.urlsplit(
endpoint if '://' in endpoint else 'socks5://' + endpoint
)
if parsed.scheme != 'socks5' or parsed.path or parsed.query or parsed.fragment:
raise ValueError()
host = parsed.hostname
if host == 'localhost':
host = '127.0.0.1'
address = ipaddress.ip_address(host)
if address.is_unspecified:
address = ipaddress.ip_address(
'::1' if address.version == 6 else '127.0.0.1'
)
port = parsed.port
if port is None or not 1 <= port <= 65535:
raise ValueError()
except (ValueError, TypeError):
raise ValueError(
'Invalid local SOCKS endpoint (IP address and port required)'
) from None
host = '[' + str(address) + ']' if address.version == 6 else str(address)
credentials = ''
if parsed.username is not None:
username = urllib.parse.unquote(parsed.username)
password = urllib.parse.unquote(parsed.password or '')
if (
not username
or not password
or max(len(username.encode()), len(password.encode())) > 255
):
raise ValueError('Invalid SOCKS authentication credentials')
credentials = (
urllib.parse.quote(username, safe='')
+ ':'
+ urllib.parse.quote(password, safe='')
+ '@'
)
return 'socks5://' + credentials + host + ':' + str(port)
+4
View File
@@ -37,6 +37,7 @@ from .Constants import (
APPLICATION_TUN2SOCKS_IP_ADDRESS,
APPLICATION_TUN2SOCKS_INTERFACE_DNS_ADDRESS,
APPLICATION_TUN2SOCKS_NETWORK_INTERFACE_NAME,
APPLICATION_TUN_BACKEND,
APPLICATION_VERSION,
CORE_CHECK_ALIVE_INTERVAL,
CRASH_LOG_DIR,
@@ -58,6 +59,7 @@ from .Constants import (
PYSIDE6_VERSION,
ROOT_DIR,
SYSTEM_LANGUAGE,
TUN_BACKENDS,
URL_GEOIP,
URL_GEOIP_SHA256,
URL_GEOSITE,
@@ -123,6 +125,7 @@ __all__ = [
'APPLICATION_TUN2SOCKS_IP_ADDRESS',
'APPLICATION_TUN2SOCKS_INTERFACE_DNS_ADDRESS',
'APPLICATION_TUN2SOCKS_NETWORK_INTERFACE_NAME',
'APPLICATION_TUN_BACKEND',
'APPLICATION_VERSION',
'ApplicationTheme',
'AppBinarySettings',
@@ -164,6 +167,7 @@ __all__ = [
'PySide6Legacy',
'ROOT_DIR',
'SYSTEM_LANGUAGE',
'TUN_BACKENDS',
'StartupOnBoot',
'SystemProxy',
'SystemRoutingTable',
+4
View File
@@ -219,6 +219,10 @@ class CoreConfiguration(dict):
return ''
def applicationTUNProxy(self) -> str:
"""Return the SOCKS transit endpoint; backends validate listener policy."""
return self.socksProxy()
def remoteAddress(self) -> str:
"""Return the remote host that must remain reachable outside TUN."""
return str(getattr(self, 'itemAddress', ''))
+4
View File
@@ -277,6 +277,10 @@ class ServerProfile(MutableMapping[str, Any]):
"""Return the connection's SOCKS proxy endpoint."""
return self.connection.socksProxy()
def applicationTUNProxy(self) -> str:
"""Delegate SOCKS transit policy to the selected backend document."""
return self.connection.applicationTUNProxy()
def remoteAddress(self) -> str:
"""Return the connection's operational remote host."""
return self.connection.remoteAddress()
+364
View File
@@ -0,0 +1,364 @@
# 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/>.
"""Pure configuration preparation for the application sing-tun bridge."""
from __future__ import annotations
import copy
import functools
import ipaddress
import math
import re
SING_TUN_HOST_DEFAULTS = {
'primaryAdapterInterfaceName': '',
'primaryAdapterInterfaceIP': '',
'tunAdapterInterfaceDNS': '',
'bypassTUNAdapterInterfaceIP': '',
'disablePrimaryAdapterInterfaceDNS': True,
}
SING_TUN_DEFAULTS = {
'stack': 'go',
'log_level': 'error',
'network_interface': '',
'max_sessions': 1024,
'udp_timeout': 60,
'connect_timeout': 10,
'tcp_idle_timeout': 300,
'tun_options': {
'Name': '',
'MTU': 1500,
'Inet4Address': ['198.18.0.1/15'],
'Inet6Address': [],
'AutoRoute': True,
'EXP_ExternalConfiguration': False,
'DNSMode': 'disabled',
'EXP_DisableDNSHijack': True,
},
'stack_options': {'UDPTimeout': '1m'},
'host_options': SING_TUN_HOST_DEFAULTS,
}
_OWNED = {
'AutoRoute': True,
'EXP_ExternalConfiguration': False,
'DNSMode': 'disabled',
'EXP_DisableDNSHijack': True,
}
_PREFIXES = {
'Inet4Address': 4,
'Inet6Address': 6,
'Inet4RouteAddress': 4,
'Inet6RouteAddress': 6,
'Inet4RouteExcludeAddress': 4,
'Inet6RouteExcludeAddress': 6,
}
_TUN_FIELDS = {*_PREFIXES, *_OWNED, 'Name', 'MTU', 'StrictRoute', 'InterfaceScope'}
_STACK_FIELDS = {
'UDPTimeout',
'ICMPTimeout',
'UDPNATMax',
'UDPMapping',
'UDPFiltering',
'ForwarderBindInterface',
'IncludeAllNetworks',
'TCPCongestionControl',
}
class SingTUNHostSettingsCallers:
"""Bind sing-tun's named host settings callers to a captured host document."""
def __init__(self, settings):
if not isinstance(settings, dict):
raise ValueError('host_options must be an object')
(
self.userPrimaryAdapterInterfaceName,
self.userPrimaryAdapterInterfaceIP,
self.userTunAdapterInterfaceDNS,
self.userBypassTUNAdapterInterfaceIP,
self.userDisablePrimaryAdapterInterfaceDNS,
) = (
functools.partial(
settings.get,
'primaryAdapterInterfaceName',
SING_TUN_HOST_DEFAULTS['primaryAdapterInterfaceName'],
),
functools.partial(
settings.get,
'primaryAdapterInterfaceIP',
SING_TUN_HOST_DEFAULTS['primaryAdapterInterfaceIP'],
),
functools.partial(
settings.get,
'tunAdapterInterfaceDNS',
SING_TUN_HOST_DEFAULTS['tunAdapterInterfaceDNS'],
),
functools.partial(
settings.get,
'bypassTUNAdapterInterfaceIP',
SING_TUN_HOST_DEFAULTS['bypassTUNAdapterInterfaceIP'],
),
functools.partial(
settings.get,
'disablePrimaryAdapterInterfaceDNS',
SING_TUN_HOST_DEFAULTS['disablePrimaryAdapterInterfaceDNS'],
),
)
def durationSeconds(value):
"""Read Go duration strings (or integer nanoseconds), without native imports."""
if isinstance(value, int) and not isinstance(value, bool):
return value / 1e9
if not isinstance(value, str) or len(value) > 80:
raise ValueError('Use a Go duration such as 1m or 30s')
parts = re.findall(r'(\d+(?:\.\d+)?)(ns|us|µs|μs|ms|s|m|h)', value)
if not parts or ''.join(number + unit for number, unit in parts) != value:
raise ValueError('Use a Go duration such as 1m or 30s')
units = {
'ns': 1e-9,
'us': 1e-6,
'µs': 1e-6,
'μs': 1e-6,
'ms': 0.001,
's': 1,
'm': 60,
'h': 3600,
}
return sum(float(number) * units[unit] for number, unit in parts)
def prepareSingTUNSettings(document, *, platform=None):
"""Validate copied settings; the stored document is never normalized in place."""
if not isinstance(document, dict):
raise ValueError('sing-tun settings must be an object')
if set(document) - set(SING_TUN_DEFAULTS):
raise ValueError('Unsupported sing-tun settings fields')
result = copy.deepcopy(SING_TUN_DEFAULTS)
for key, value in document.items():
if key in ('tun_options', 'stack_options', 'host_options'):
if not isinstance(value, dict):
raise ValueError(key + ' must be an object')
result[key].update(copy.deepcopy(value))
else:
result[key] = copy.deepcopy(value)
if result['stack'] not in ('', 'go', 'gvisor', 'system', 'mixed'):
raise ValueError('Unsupported sing-tun stack')
if result['log_level'] not in ('trace', 'debug', 'info', 'warn', 'error', 'silent'):
raise ValueError('Invalid sing-tun log level')
for key in ('network_interface',):
if (
not isinstance(result[key], str)
or len(result[key]) > 256
or any(c in result[key] for c in '\x00\r\n')
):
raise ValueError('Invalid ' + key)
sessions = result['max_sessions']
if type(sessions) is not int or not 1 <= sessions <= 16384:
raise ValueError('max_sessions must be between 1 and 16384')
for key in ('udp_timeout', 'connect_timeout', 'tcp_idle_timeout'):
value = result[key]
if (
type(value) not in (int, float)
or not math.isfinite(value)
or not 0.05 <= value <= 86400
):
raise ValueError(key + ' must be between 0.05 and 86400 seconds')
tun, stack = result['tun_options'], result['stack_options']
if set(tun) - _TUN_FIELDS or set(stack) - _STACK_FIELDS:
raise ValueError('Unsupported or application-owned native option')
for key, expected in _OWNED.items():
if type(tun[key]) is not type(expected) or tun[key] != expected:
raise ValueError(key + ' is managed by Furious')
if not isinstance(tun['Name'], str) or not re.fullmatch(
r'[a-zA-Z0-9_.-]{0,32}', tun['Name']
):
raise ValueError('Invalid TUN device name')
if type(tun['MTU']) is not int or not 68 <= tun['MTU'] <= 65535:
raise ValueError('MTU must be between 68 and 65535')
for key, family in _PREFIXES.items():
if key not in tun:
continue
if not isinstance(tun[key], list) or len(tun[key]) > 256:
raise ValueError(key + ' must be a bounded prefix list')
for prefix in tun[key]:
try:
if (
not isinstance(prefix, str)
or ipaddress.ip_interface(prefix).version != family
):
raise ValueError()
except (ValueError, TypeError):
raise ValueError('Invalid prefix in ' + key) from None
if not tun['Inet4Address'] and not tun['Inet6Address']:
raise ValueError('At least one TUN address prefix is required')
if tun['Inet6Address'] and tun['MTU'] < 1280:
raise ValueError('IPv6 requires an MTU of at least 1280 bytes')
if tun.get('StrictRoute') and (not tun['Inet4Address'] or not tun['Inet6Address']):
raise ValueError(
'StrictRoute requires both address families to preserve remote bypasses'
)
if platform == 'Darwin' and tun.get('InterfaceScope'):
raise ValueError(
'Scoped macOS routes require binding support for scoped route cleanup'
)
for options, keys in (
(tun, ('StrictRoute', 'InterfaceScope')),
(stack, ('ForwarderBindInterface', 'IncludeAllNetworks')),
):
for key in keys:
if key in options and type(options[key]) is not bool:
raise ValueError(key + ' must be a boolean')
if stack.get('IncludeAllNetworks') and result['stack'] != 'gvisor':
raise ValueError('IncludeAllNetworks requires an explicit gvisor stack')
if stack.get('ForwarderBindInterface') and result['stack'] in ('', 'go'):
raise ValueError('ForwarderBindInterface is not used by the go stack')
congestion = stack.get('TCPCongestionControl', '')
if type(congestion) is not str or congestion not in ('', 'cubic', 'reno', 'bbr'):
raise ValueError('Invalid TCPCongestionControl')
if congestion and result['stack'] not in ('', 'go'):
raise ValueError('TCPCongestionControl requires go or automatic stack')
for key in ('UDPTimeout', 'ICMPTimeout'):
if key in stack:
seconds = durationSeconds(stack[key])
if (
not math.isfinite(seconds)
or seconds < 0
or (key == 'UDPTimeout' and seconds == 0)
or seconds > 86400
):
raise ValueError('Invalid ' + key)
for key, maximum in (
('UDPNATMax', 4294967295),
('UDPMapping', 2),
('UDPFiltering', 2),
):
if key in stack and (
type(stack[key]) is not int or not 0 <= stack[key] <= maximum
):
raise ValueError('Invalid ' + key)
result['host_options'] = prepareSingHostSettings(result['host_options'])
return result
def prepareSingHostSettings(document):
"""Validate sing-tun's own DNS, physical adapter and bypass preferences."""
if not isinstance(document, dict):
raise ValueError('sing-tun host options must be an object')
if set(document) - set(SING_TUN_HOST_DEFAULTS):
raise ValueError('Unsupported sing-tun host options')
result = copy.deepcopy(SING_TUN_HOST_DEFAULTS)
result.update(copy.deepcopy(document))
for key in (
'primaryAdapterInterfaceName',
'primaryAdapterInterfaceIP',
'tunAdapterInterfaceDNS',
'bypassTUNAdapterInterfaceIP',
):
value = result[key]
if (
not isinstance(value, str)
or len(value) > 8192
or any(char in value for char in '\x00\r\n')
):
raise ValueError('Invalid sing-tun host field: ' + key)
if key.endswith('IP') or key == 'tunAdapterInterfaceDNS':
try:
addresses = (
value.split(',')
if key == 'bypassTUNAdapterInterfaceIP'
else [value]
)
for address in addresses if value else []:
ipaddress.ip_address(address.strip())
except ValueError:
raise ValueError(
'Invalid IP address in sing-tun host field: ' + key
) from None
if type(result['disablePrimaryAdapterInterfaceDNS']) is not bool:
raise ValueError('sing-tun primary adapter DNS mitigation must be a boolean')
return result
def addSingTUNExclusions(settings, addresses):
"""Merge attempt-only outbound exclusions with explicit user ranges."""
prepared = copy.deepcopy(settings)
for text in addresses:
address = ipaddress.ip_address(text)
key = (
'Inet4RouteExcludeAddress'
if address.version == 4
else 'Inet6RouteExcludeAddress'
)
entries = prepared['tun_options'].setdefault(key, [])
prefix = str(address) + ('/32' if address.version == 4 else '/128')
if prefix not in entries:
entries.append(prefix)
return prepared
+19
View File
@@ -20,10 +20,12 @@
from __future__ import annotations
from Furious.Frozenlib import *
from Furious.Frozenlib.SocksProxy import socksURL
from PySide6.QtNetwork import *
from typing import Union
from urllib.parse import urlsplit, unquote
import weakref
import functools
@@ -85,3 +87,20 @@ class AppQNetworkAccessManager(QNetworkAccessManager):
self.setProxy(QNetworkProxy.ProxyType.NoProxy)
return useProxy
def configureSocksProxy(self, endpoint: str):
"""Use validated SOCKS transit without a direct-network fallback."""
proxy = urlsplit(socksURL(endpoint))
self.setProxy(
QNetworkProxy(
QNetworkProxy.ProxyType.Socks5Proxy,
proxy.hostname,
proxy.port,
unquote(proxy.username or ''),
unquote(proxy.password or ''),
)
)
return True
+82
View File
@@ -0,0 +1,82 @@
# 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/>.
"""Persist sing-tun's independent native and host configuration."""
from __future__ import annotations
from Furious.Frozenlib import AppSettings, Mixins, registerAppSettings
from Furious.Interface import StorageBackend
from Furious.Models.Encoding import PyBase64Encoder, UJSONEncoder
import logging
__all__ = ['UserSingTUNSettings']
logger = logging.getLogger(__name__)
# Keep sing-tun customizations independent of the legacy CustomTUNSettings.
# 0.8.2 ignores this key and preserves it during ordinary settings writes;
# an explicit settings reset clears both engines' customizations.
registerAppSettings('CustomSingTUNSettings')
class UserSingTUNSettings(Mixins.CleanupOnExit, StorageBackend):
"""Own only the sing-tun document, including its host options."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._restoreFailed = False
raw = AppSettings.get('CustomSingTUNSettings')
self._data = {}
if raw is None:
return
try:
data = UJSONEncoder.decode(PyBase64Encoder.decode(raw))
if not isinstance(data, dict):
raise TypeError('sing-tun settings repository root must be an object')
self._data = data
except Exception:
self._restoreFailed = True
logger.exception('failed to restore persisted sing-tun settings')
def data(self) -> dict:
return self._data
def sync(self):
AppSettings.set(
'CustomSingTUNSettings',
PyBase64Encoder.encode(UJSONEncoder.encode(self._data).encode()),
)
self._restoreFailed = False
def cleanup(self):
if self._restoreFailed and not self._data:
logger.warning('preserving unreadable sing-tun settings during cleanup')
return
self.sync()
+44
View File
@@ -26,10 +26,16 @@ from Furious.Repository.Routings import UserRoutings
from Furious.Repository.Servers import UserServers
from Furious.Repository.Subscriptions import SubscriptionGroup, UserSubs
from Furious.Repository.TunSettings import UserTUNSettings
from Furious.Repository.SingTUNSettings import UserSingTUNSettings
from Furious.Models.SingTUN import prepareSingTUNSettings
from PySide6 import QtCore
from typing import Union
import functools
import copy
import json
__all__ = ['Storage']
@@ -61,6 +67,44 @@ class Storage:
return UserTUNSettings()
@staticmethod
@functools.lru_cache(None)
def _UserSingTUNSettingsStorage() -> UserSingTUNSettings:
"""Return the single sing-tun customization repository."""
assert APP() is not None
return UserSingTUNSettings()
@staticmethod
def UserSingTUNSettings() -> dict:
"""Expose the compatibility live view of sing-tun settings."""
return Storage._UserSingTUNSettingsStorage().data()
@staticmethod
def replaceSingTUNSettings(singSettings):
"""Commit sing-tun's validated document, then report its flush result."""
prepareSingTUNSettings(singSettings)
# Serialization is a fallible pre-commit stage.
json.dumps(singSettings, allow_nan=False)
singCandidate = copy.deepcopy(singSettings)
sing = Storage._UserSingTUNSettingsStorage()
sing.data().clear()
sing.data().update(singCandidate)
sing.sync()
settings = QtCore.QSettings()
settings.sync()
if settings.status() != QtCore.QSettings.Status.NoError:
raise OSError(
'sing-tun settings committed in memory, but could not be flushed to disk'
)
@staticmethod
@functools.lru_cache(None)
def _UserRoutingsStorage() -> UserRoutings:
+2
View File
@@ -29,6 +29,8 @@ __all__ = ['UserTUNSettings']
logger = logging.getLogger(__name__)
# Preserve the legacy tun2socks key and Base64/JSON format used by 0.8.2.
# These values remain tun2socks-specific; sing-tun owns a separate document.
registerAppSettings('CustomTUNSettings')
+19 -2
View File
@@ -25,13 +25,26 @@ for execution, and Qt for lifetime primitives. This scope owns multi-stage workf
## Connection and network workflows
- GUI connection startup is a generation-checked transaction over a runtime copy: prepare TUN policy, launch and
observe the primary runtime, acquire optional tun2socks/DNS resources, and mutate host networking in platform
order before commit. Preserve Windows runtime-before-device, Linux device-before-runtime, and macOS
observe the primary runtime, acquire optional application TUN/DNS resources, and mutate host networking in platform
order before commit. The tun2socks path preserves Windows runtime-before-device, Linux device-before-runtime, and macOS
survival-before-DNS ordering. Failure/cancellation releases attempt-owned runtimes and registered host cleanup.
The synchronous start path remains a compatibility boundary. Review GUI responsiveness at each stage, including
factory preparation, host commands, and reverse cleanup: a scheduled start only defers the first call. Readiness
timers and asynchronous DNS cannot preempt synchronous work. Keep cancellation checks at reentrant stage boundaries
before acquiring the next resource, and audit shared route bookkeeping separately from attempt-local leases.
- Native proxy-core TUN policy precedes application-engine selection. The legacy plugin opt-in
`usesApplicationTun2socks` still means application-TUN eligibility; it must not force the selected engine.
Snapshot the engine and relevant customization once per attempt; bind each backend's named settings callers to
that snapshot, preserving proxy-only and explicit native TUN
behavior. Each application engine reads only its own persisted document: sing-tun's `host_options` belong to
`CustomSingTUNSettings`, while tun2socks uses `CustomTUNSettings`; edits and missing defaults must never import
preferences from the other engine. Their repository/host policies remain independent while orchestration,
leases and general utilities may be reused. For sing-tun, resolve automatic and manual remote exclusions before native auto-routing, then require
native readiness and checked DNS application before commit. `SingTUNHostPlan` owns only its assigned identifiers
and DNS snapshots, independently of legacy `SystemRoutingTable.managedRoutes`; refuse ambiguous recovery and
retain ownership for retry. Host preparation/DNS commands run in its owned worker, while synchronous compatibility
startup and bounded cleanup joins remain explicit responsiveness limits. Consult `tests/test_sing_tun.py` and
`tests/test_native_tun_semantics.py`; mocked host tests do not establish privileged OS behavior.
- Construct a runtime event router before asking a plugin to create its runtime. One lease owns the runtime/router from
acquisition through attempt ownership, commit, and reverse-order release; commit changes logical delivery without
replacing the runtime callback. Worker-thread exits are queued to the router's Qt thread, delivered at most once, and
@@ -74,6 +87,10 @@ for execution, and Qt for lifetime primitives. This scope owns multi-stage workf
- Log cursors are opaque and filter-specific. A generation change requires a reset; retention-only eviction supplies
a new first-retained sequence so presenters can prune their prefix without rebuilding history. Capture entries and
the next cursor atomically, and coalesce notifications without losing producer updates.
- Application TUN logs share an engine-neutral runtime category. Bind each producer's source to the connection
attempt's captured engine selection rather than a later preference read; proxy-core native TUN output stays with
the Core category. Preserve batching, export/rendering labels, and runtime clearing together. Tests in
`test_sing_tun.py`, `test_log_manager_generation.py`, and `test_ui_behavior.py` cover these boundaries.
- Metrics sampling owns its worker/future generation and rejects results after disconnect, disablement, replacement,
or shutdown. Normalize cumulative-counter resets before history aggregation; clearing usage must not erase speed
history.
+356 -56
View File
@@ -25,6 +25,8 @@ from Furious.Frozenlib import (
APPLICATION_TUN2SOCKS_INTERFACE_DNS_ADDRESS,
APPLICATION_TUN2SOCKS_IP_ADDRESS,
APPLICATION_TUN2SOCKS_NETWORK_INTERFACE_NAME,
APPLICATION_TUN_BACKEND,
AppSettings,
Mixins,
PLATFORM,
PySide6Legacy,
@@ -45,17 +47,25 @@ from Furious.Plugins import (
from Furious.Qt.Signals import connectWeakly, singleShotWeakly
from Furious.Service.DnsResolver import DnsResolver
from Furious.Core.Tun2socks import Tun2socks
from Furious.Core.SingTUN import SingTUN
from Furious.Models.SingTUN import SingTUNHostSettingsCallers, prepareSingTUNSettings
from Furious.Frozenlib.SocksProxy import socksURL
from Furious.Service.SingTUNHost import SingTUNHostPlan
from Furious.Service.RuntimeLease import RuntimeEventRouter, RuntimeLease
from PySide6 import QtCore, QtNetwork
from typing import Callable, Tuple, Union
from dataclasses import dataclass, field
from enum import Enum
from PySide6 import QtCore, QtNetwork
import os
import logging
import tempfile
import functools
import copy
import time
import ipaddress
__all__ = ['ConnectionManager', 'ConnectionStartOperation', 'ConnectionStartStage']
@@ -72,6 +82,37 @@ class _ConnectionStartAttempt:
nativeTUNHandled: bool = False
applicationTun2socks: bool = False
committed: bool = False
tunBackend: str = field(
default_factory=lambda: AppSettings.get(APPLICATION_TUN_BACKEND)
)
singSettings: dict = field(default_factory=dict)
tun2socksSettings: dict = field(default_factory=dict)
singHostSettingsCallers: SingTUNHostSettingsCallers | None = field(
default=None, init=False, repr=False
)
tun2socksSettingsCallers: _Tun2socksSettingsCallers | None = field(
default=None, init=False, repr=False
)
def applicationTUNLogCallback(self, callback):
"""Bind the engine captured by this attempt, preserving plain callbacks."""
copyWithSource = getattr(callback, 'copyWithSource', None)
return copyWithSource(self.tunBackend) if callable(copyWithSource) else callback
def snapshotApplicationTUN(self):
"""Read engine settings only when the plugin requires application TUN."""
if self.applicationTun2socks:
if self.tunBackend == 'sing-tun':
self.singSettings = copy.deepcopy(Storage.UserSingTUNSettings())
self.singHostSettingsCallers = SingTUNHostSettingsCallers(
self.singSettings.get('host_options', {})
)
else:
self.tun2socksSettings = copy.deepcopy(Storage.UserTUNSettings())
self.tun2socksSettingsCallers = _Tun2socksSettingsCallers(
self.tun2socksSettings
)
@property
def runtimes(self):
@@ -120,32 +161,33 @@ class _ConnectionStartAttempt:
self.committed = True
def getUserTUNSettings(*args, **kwargs):
"""Return one user-defined TUN setting or its fallback value."""
return Storage.UserTUNSettings().get(*args, **kwargs)
class _Tun2socksSettingsCallers:
"""Bind named tun2socks settings callers to one attempt's captured document."""
(
userPrimaryAdapterInterfaceName,
userPrimaryAdapterInterfaceIP,
userDefaultPrimaryGatewayIP,
userTunAdapterInterfaceDNS,
userBypassTUNAdapterInterfaceIP,
userDisablePrimaryAdapterInterfaceDNS,
userTcpSendBufferSize,
userTcpReceiveBufferSize,
userTcpAutoTuning,
) = (
functools.partial(getUserTUNSettings, 'primaryAdapterInterfaceName', ''),
functools.partial(getUserTUNSettings, 'primaryAdapterInterfaceIP', ''),
functools.partial(getUserTUNSettings, 'defaultPrimaryGatewayIP', ''),
functools.partial(getUserTUNSettings, 'tunAdapterInterfaceDNS', ''),
functools.partial(getUserTUNSettings, 'bypassTUNAdapterInterfaceIP', ''),
functools.partial(getUserTUNSettings, 'disablePrimaryAdapterInterfaceDNS', 'True'),
functools.partial(getUserTUNSettings, 'tcpSendBufferSize', 1),
functools.partial(getUserTUNSettings, 'tcpReceiveBufferSize', 1),
functools.partial(getUserTUNSettings, 'tcpAutoTuning', 'False'),
)
def __init__(self, settings):
(
self.userPrimaryAdapterInterfaceName,
self.userPrimaryAdapterInterfaceIP,
self.userDefaultPrimaryGatewayIP,
self.userTunAdapterInterfaceDNS,
self.userBypassTUNAdapterInterfaceIP,
self.userDisablePrimaryAdapterInterfaceDNS,
self.userTcpSendBufferSize,
self.userTcpReceiveBufferSize,
self.userTcpAutoTuning,
) = (
functools.partial(settings.get, 'primaryAdapterInterfaceName', ''),
functools.partial(settings.get, 'primaryAdapterInterfaceIP', ''),
functools.partial(settings.get, 'defaultPrimaryGatewayIP', ''),
functools.partial(settings.get, 'tunAdapterInterfaceDNS', ''),
functools.partial(settings.get, 'bypassTUNAdapterInterfaceIP', ''),
functools.partial(
settings.get, 'disablePrimaryAdapterInterfaceDNS', 'True'
),
functools.partial(settings.get, 'tcpSendBufferSize', 1),
functools.partial(settings.get, 'tcpReceiveBufferSize', 1),
functools.partial(settings.get, 'tcpAutoTuning', 'False'),
)
class ConnectionStartStage(Enum):
@@ -221,10 +263,21 @@ class _RuntimeReadinessProbe(QtCore.QObject):
if self._terminal:
return
if isinstance(self._runtime, SingTUN):
if self._runtime.startupError:
self._finishFailed(self._runtime.startupError)
return
if self._runtime.ready:
self._finishReady()
return
timeout = max(int(self._startup.timeout), 1)
if self._elapsed.isValid() and self._elapsed.elapsed() >= timeout:
if self._startup.endpoint:
if self._startup.endpoint or isinstance(self._runtime, SingTUN):
self._finishFailed('core readiness check timed out')
else:
self._finishReady()
@@ -377,7 +430,6 @@ class ConnectionStartOperation(QtCore.QObject):
self.routing = routing
self.exitCallback = exitCallback
self.msgCallbackCore = msgCallbackCore
self.msgCallbackTUN_ = msgCallbackTUN_
self.proxyModeOnly = proxyModeOnly
self.log = log
self.options = dict(options or {})
@@ -385,6 +437,7 @@ class ConnectionStartOperation(QtCore.QObject):
self.attempt = _ConnectionStartAttempt(
manager, manager._runtimeConfiguration(config, deepcopy)
)
self.msgCallbackTUN_ = self.attempt.applicationTUNLogCallback(msgCallbackTUN_)
self.stage = ConnectionStartStage.Pending
@@ -392,6 +445,7 @@ class ConnectionStartOperation(QtCore.QObject):
self._readinessProbe = None
self._conditionProbe = None
self._conditionContinuation = ''
self._conditionFailureMessage = 'TUN device did not become ready'
self._dnsOperation = None
self._tun = None
self._startTUN = None
@@ -443,6 +497,8 @@ class ConnectionStartOperation(QtCore.QObject):
self.attempt.nativeTUNHandled,
self.attempt.applicationTun2socks,
) = self.manager._prepareTUNPolicy(configcopy, self.proxyModeOnly)
self.attempt.snapshotApplicationTUN()
except TUNPreparationError as ex:
self.manager._lastStartError = str(ex)
@@ -608,6 +664,11 @@ class ConnectionStartOperation(QtCore.QObject):
if not self._isCurrent():
return
if self.attempt.tunBackend == 'sing-tun':
self._resume('_beginSingTUN')
return
self._setStage(ConnectionStartStage.PreparingTUN)
if not self._isCurrent():
@@ -621,8 +682,9 @@ class ConnectionStartOperation(QtCore.QObject):
APPLICATION_TUN2SOCKS_GATEWAY_ADDRESS,
)
userGateway = userDefaultPrimaryGatewayIP()
userInterfaceIP = userPrimaryAdapterInterfaceIP()
settings = self.attempt.tun2socksSettingsCallers
userGateway = settings.userDefaultPrimaryGatewayIP()
userInterfaceIP = settings.userPrimaryAdapterInterfaceIP()
if userGateway and userInterfaceIP:
logger.info(
@@ -663,9 +725,9 @@ class ConnectionStartOperation(QtCore.QObject):
return
tcpSendBufferSize = userTcpSendBufferSize()
tcpReceiveBufferSize = userTcpReceiveBufferSize()
tcpAutoTuning = userTcpAutoTuning() == 'True'
tcpSendBufferSize = settings.userTcpSendBufferSize()
tcpReceiveBufferSize = settings.userTcpReceiveBufferSize()
tcpAutoTuning = settings.userTcpAutoTuning() == 'True'
interfaceArg = (
APPLICATION_TUN2SOCKS_NETWORK_INTERFACE_NAME
if PLATFORM != 'Linux'
@@ -715,12 +777,132 @@ class ConnectionStartOperation(QtCore.QObject):
else:
self._resume('_prepareTunBypass')
def _beginSingTUN(self):
"""Validate before activation, then resolve remote exclusions via the core."""
self._setStage(ConnectionStartStage.PreparingTUN)
if not self._isCurrent():
return
router = RuntimeEventRouter()
try:
self._tun = self.manager._createSingTUN(
self.attempt, router.publish, self.msgCallbackTUN_
)
except BaseException:
router.finishRelease()
router.deleteLater()
raise
router.attach(self._tun, self)
self.attempt.ownRuntime(self._tun, router)
address = self.attempt.runtimeConfiguration.remoteAddress()
if isValidIPAddress(address):
self._singAddressesResolved(False, [address])
return
if not address:
raise ValueError('A remote destination is required for sing-tun bypass')
resolver = self.manager._connectionDnsResolver()
self.manager._configureTunResolver(resolver, self.attempt.runtimeConfiguration)
self._setStage(ConnectionStartStage.ResolvingTUNAddress)
if not self._isCurrent():
return
operation = resolver.resolveAsync(address, parent=self)
self._dnsOperation = operation
connectWeakly(operation.finished, self, '_singAddressesResolved')
operation.start()
def _singAddressesResolved(self, error, addresses):
if not self._isCurrent():
return
operation, self._dnsOperation = self._dnsOperation, None
if operation is not None:
operation.deleteLater()
if error or not addresses:
self._fail(
'Failed to prepare sing-tun', 'Remote bypass DNS resolution failed'
)
return
try:
addresses = self.manager._singBypassAddresses(self.attempt, addresses)
self._tun._hostPlan.begin('prepare', addresses)
self._waitSingHost('_startSingTUN')
except Exception as ex:
self._fail('Failed to prepare sing-tun', str(ex))
def _waitSingHost(self, continuation):
if not self._isCurrent():
return
self._observeCondition(
self._tun._hostPlan.workCompleted,
'sing-tun host preparation',
continuation,
self.stage,
'sing-tun host preparation timed out',
)
def _startSingTUN(self):
if self._tun._hostPlan.error:
raise RuntimeStartError(self._tun._hostPlan.error)
self._setStage(ConnectionStartStage.StartingTUNRuntime)
if not self._isCurrent():
return
self._tun.start()
self._observeRuntime(
self._tun,
CoreRuntimeStartup(timeout=15000),
'_applySingDNS',
ConnectionStartStage.WaitingTUNRuntime,
)
def _applySingDNS(self):
self._setStage(ConnectionStartStage.ApplyingHostNetwork)
if not self._isCurrent():
return
self._tun._hostPlan.begin('applyDNS', self._tun.deviceName)
self._waitSingHost('_singHostReady')
def _singHostReady(self):
if self._tun._hostPlan.error:
raise RuntimeStartError(self._tun._hostPlan.error)
self._commit()
def _prepareTunBypass(self):
"""Prepare remote-server bypass routes without blocking for DNS."""
if not self._isCurrent():
return
bypassTUN = userBypassTUNAdapterInterfaceIP()
bypassTUN = (
self.attempt.tun2socksSettingsCallers.userBypassTUNAdapterInterfaceIP()
)
if bypassTUN:
for bypass in bypassTUN.split(','):
@@ -814,19 +996,32 @@ class ConnectionStartOperation(QtCore.QObject):
def _waitForTunDevice(self, predicate, continuation):
"""Wait for one platform TUN device through a Qt timer."""
self._observeCondition(
predicate,
f'TUN device {APPLICATION_TUN2SOCKS_DEVICE_NAME!r}',
continuation,
ConnectionStartStage.WaitingTUNDevice,
'TUN device did not become ready',
)
def _observeCondition(
self, predicate, description, continuation, stage, failureMessage
):
"""Observe an owned worker or device without inventing device readiness."""
if not self._isCurrent():
return
self._setStage(ConnectionStartStage.WaitingTUNDevice)
self._setStage(stage)
if not self._isCurrent():
return
self._conditionContinuation = continuation
self._conditionFailureMessage = failureMessage
probe = _ConditionProbe(
predicate,
f'TUN device {APPLICATION_TUN2SOCKS_DEVICE_NAME!r}',
description,
parent=self,
)
@@ -850,7 +1045,7 @@ class ConnectionStartOperation(QtCore.QObject):
probe.deleteLater()
if not success:
self._fail('', 'TUN device did not become ready')
self._fail('', self._conditionFailureMessage)
return
@@ -863,7 +1058,8 @@ class ConnectionStartOperation(QtCore.QObject):
self._setStage(ConnectionStartStage.ApplyingHostNetwork)
userInterfaceName = userPrimaryAdapterInterfaceName()
settings = self.attempt.tun2socksSettingsCallers
userInterfaceName = settings.userPrimaryAdapterInterfaceName()
alias = (
userInterfaceName
if userInterfaceName
@@ -878,7 +1074,7 @@ class ConnectionStartOperation(QtCore.QObject):
self._tun.cleanup = functools.partial(_windowsCleanup, alias)
if userDisablePrimaryAdapterInterfaceDNS() != 'False':
if settings.userDisablePrimaryAdapterInterfaceDNS() != 'False':
SystemRoutingTable.WIN32SetInterfaceDNS(
alias,
'127.0.0.1',
@@ -886,7 +1082,8 @@ class ConnectionStartOperation(QtCore.QObject):
)
interfaceDNS = (
userTunAdapterInterfaceDNS() or APPLICATION_TUN2SOCKS_INTERFACE_DNS_ADDRESS
settings.userTunAdapterInterfaceDNS()
or APPLICATION_TUN2SOCKS_INTERFACE_DNS_ADDRESS
)
SystemRoutingTable.addRelations()
@@ -928,7 +1125,8 @@ class ConnectionStartOperation(QtCore.QObject):
self._tun.cleanup = functools.partial(_darwinCleanup, servers)
interfaceDNS = (
userTunAdapterInterfaceDNS() or APPLICATION_TUN2SOCKS_INTERFACE_DNS_ADDRESS
self.attempt.tun2socksSettingsCallers.userTunAdapterInterfaceDNS()
or APPLICATION_TUN2SOCKS_INTERFACE_DNS_ADDRESS
)
for service, _dnsserver in servers:
@@ -1199,12 +1397,12 @@ class ConnectionManager(Mixins.CleanupOnExit):
if useAppTun2socks:
logger.info(
f'application-managed tun2socks selected. Remote '
f'application-managed TUN selected. Remote '
f'address: {config.remoteAddress()!r}'
)
else:
logger.info(
'application-managed tun2socks skipped by the active '
'application-managed TUN skipped by the active '
'core runtime configuration'
)
@@ -1389,6 +1587,10 @@ class ConnectionManager(Mixins.CleanupOnExit):
tunModeRequested = not proxyModeOnly and SystemRuntime.isTUNMode()
attempt.snapshotApplicationTUN()
msgCallbackTUN_ = attempt.applicationTUNLogCallback(msgCallbackTUN_)
if not self._startPrimaryRuntime(
attempt,
routing,
@@ -1401,15 +1603,110 @@ class ConnectionManager(Mixins.CleanupOnExit):
return abortStart()
if tunModeRequested and attempt.applicationTun2socks:
if not self._startApplicationTun2socks(
attempt, exitCallback, msgCallbackTUN_
):
startApplicationTUN = (
self._startApplicationSingTUN
if attempt.tunBackend == 'sing-tun'
else self._startApplicationTun2socks
)
if not startApplicationTUN(attempt, exitCallback, msgCallbackTUN_):
return False
attempt.commit()
return True
@staticmethod
def _singBypassAddresses(attempt, addresses):
result = list(addresses)
manual = attempt.singHostSettingsCallers.userBypassTUNAdapterInterfaceIP()
if manual:
result.extend(value.strip() for value in manual.split(','))
try:
return list(
dict.fromkeys(str(ipaddress.ip_address(value)) for value in result)
)
except ValueError:
raise ValueError('Invalid application TUN bypass IP address') from None
@staticmethod
def _configureTunResolver(resolver, configuration):
# SOCKS transit carries the prepared listener's authentication policy.
# Never require HTTP or fall back to a direct bootstrap request.
getter = getattr(configuration, 'applicationTUNProxy', None)
resolver.configureSocksProxy(
getter() if callable(getter) else socksURL(configuration.socksProxy())
)
@staticmethod
def _createSingTUN(attempt, exitCallback, messageCallback):
configuration = prepareSingTUNSettings(attempt.singSettings, platform=PLATFORM)
getter = getattr(attempt.runtimeConfiguration, 'applicationTUNProxy', None)
configuration['proxy'] = socksURL(
getter() if callable(getter) else attempt.runtimeConfiguration.socksProxy()
)
plan = SingTUNHostPlan(configuration)
return SingTUN(
plan.configuration,
hostPlan=plan,
exitCallback=exitCallback,
msgCallback=messageCallback,
)
def _startApplicationSingTUN(self, attempt, exitCallback, messageCallback):
"""Compatibility startup shares configuration, bypass and host ownership."""
try:
runtime = self._createSingTUN(attempt, exitCallback, messageCallback)
attempt.ownRuntime(runtime)
address = attempt.runtimeConfiguration.remoteAddress()
if isValidIPAddress(address):
addresses = [address]
else:
resolver = self._connectionDnsResolver()
self._configureTunResolver(resolver, attempt.runtimeConfiguration)
error, addresses = resolver.resolve(address)
if error or not addresses:
raise ValueError('Remote bypass DNS resolution failed')
plan = runtime._hostPlan
plan.prepare(self._singBypassAddresses(attempt, addresses))
runtime.start()
deadline = time.monotonic() + 15
while not runtime.ready:
if (
runtime.startupError
or not runtime.isRunning()
or time.monotonic() >= deadline
):
raise RuntimeStartError(
runtime.startupError or 'sing-tun readiness timed out'
)
time.sleep(0.02)
plan.applyDNS(runtime.deviceName)
return True
except Exception as ex:
self._lastStartError = str(ex)
return attempt.rollback('sing-tun startup failed: ' + self._lastStartError)
def _startApplicationTun2socks(
self,
attempt: _ConnectionStartAttempt,
@@ -1427,9 +1724,10 @@ class ConnectionManager(Mixins.CleanupOnExit):
SystemRoutingTable.delete('0.0.0.0', APPLICATION_TUN2SOCKS_GATEWAY_ADDRESS)
# Handle user defined settings
settings = attempt.tun2socksSettingsCallers
userGateway, userInterfaceIP = (
userDefaultPrimaryGatewayIP(),
userPrimaryAdapterInterfaceIP(),
settings.userDefaultPrimaryGatewayIP(),
settings.userPrimaryAdapterInterfaceIP(),
)
if userGateway and userInterfaceIP:
@@ -1470,9 +1768,9 @@ class ConnectionManager(Mixins.CleanupOnExit):
return abortStart(f'unrecognized platform: {PLATFORM}')
tcpSendBufferSize, tcpReceiveBufferSize, tcpAutoTuning = (
userTcpSendBufferSize(),
userTcpReceiveBufferSize(),
userTcpAutoTuning(),
settings.userTcpSendBufferSize(),
settings.userTcpReceiveBufferSize(),
settings.userTcpAutoTuning(),
)
if tcpSendBufferSize != 1:
@@ -1526,7 +1824,7 @@ class ConnectionManager(Mixins.CleanupOnExit):
return abortStart(f'core {Tun2socks.name()} start failed')
# Handle user defined settings
bypassTUN = userBypassTUNAdapterInterfaceIP()
bypassTUN = settings.userBypassTUNAdapterInterfaceIP()
if bypassTUN:
try:
@@ -1589,7 +1887,7 @@ class ConnectionManager(Mixins.CleanupOnExit):
return abortStart()
# Handle user defined settings
userInterfaceName = userPrimaryAdapterInterfaceName()
userInterfaceName = settings.userPrimaryAdapterInterfaceName()
if userInterfaceName:
logger.info(
@@ -1616,7 +1914,9 @@ class ConnectionManager(Mixins.CleanupOnExit):
tun.cleanup = functools.partial(_windowsCleanup, alias)
# Handle user defined settings
userDisableInterfaceDNS = userDisablePrimaryAdapterInterfaceDNS()
userDisableInterfaceDNS = (
settings.userDisablePrimaryAdapterInterfaceDNS()
)
logger.info(f'DisablePrimaryInterfaceDNS: {userDisableInterfaceDNS}')
@@ -1624,7 +1924,7 @@ class ConnectionManager(Mixins.CleanupOnExit):
SystemRoutingTable.WIN32SetInterfaceDNS(alias, '127.0.0.1', False)
# Handle user defined settings
userTunInterfaceDNS = userTunAdapterInterfaceDNS()
userTunInterfaceDNS = settings.userTunAdapterInterfaceDNS()
if userTunInterfaceDNS == '':
userTunInterfaceDNS = APPLICATION_TUN2SOCKS_INTERFACE_DNS_ADDRESS
@@ -1667,7 +1967,7 @@ class ConnectionManager(Mixins.CleanupOnExit):
tun.cleanup = functools.partial(_darwinCleanup, servers)
# Handle user defined settings
userTunInterfaceDNS = userTunAdapterInterfaceDNS()
userTunInterfaceDNS = settings.userTunAdapterInterfaceDNS()
if userTunInterfaceDNS == '':
userTunInterfaceDNS = APPLICATION_TUN2SOCKS_INTERFACE_DNS_ADDRESS
+786
View File
@@ -0,0 +1,786 @@
# 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/>.
"""Attempt-local host preparation and recovery for the native auto-route engine."""
from __future__ import annotations
from Furious.Frozenlib import (
PLATFORM,
SystemRuntime,
runExternalCommand,
SystemRoutingTable,
)
from Furious.Models.SingTUN import (
SingTUNHostSettingsCallers,
addSingTUNExclusions,
prepareSingTUNSettings,
)
import copy
import ipaddress
import json
import os
import secrets
import shutil
import socket
import subprocess
import threading
import base64
def _command(arguments):
"""Run a checked, bounded argument vector without shell interpolation."""
try:
return (
runExternalCommand(
arguments,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
timeout=5,
)
.stdout.decode('utf-8', 'replace')
.strip()
)
except (OSError, subprocess.SubprocessError):
# Command output may contain host configuration; keep the UI diagnostic
# useful without forwarding it or profile credentials to logs.
raise RuntimeError('sing-tun host operation failed: ' + arguments[0]) from None
def _powershell(script):
script = (
'[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; '
'$OutputEncoding=[Console]::OutputEncoding; ' + script
)
return _command(
[
'powershell',
'-NoProfile',
'-NonInteractive',
'-EncodedCommand',
base64.b64encode(script.encode('utf-16le')).decode(),
]
)
def _darwinRoutes(text, family):
"""Normalize netstat's abbreviated destinations, never matching substrings."""
result = []
for line in text.splitlines():
fields = line.split()
if len(fields) < 4:
continue
destination = fields[0]
try:
if destination == 'default':
destination = '0.0.0.0/0' if family == 4 else '::/0'
elif family == 4:
address, separator, mask = destination.partition('/')
octets = address.split('.')
destination = (
'.'.join(octets + ['0'] * (4 - len(octets)))
+ '/'
+ (mask if separator else str(8 * len(octets)))
)
network = ipaddress.ip_network(destination, strict=False)
except ValueError:
continue
if network.version == family:
result.append((network, fields[1], fields[3]))
return result
def _routeRanges(tun):
"""Mirror the pinned Darwin BuildAutoRouteRanges for exact conflict checks."""
result = []
for family in (4, 6):
addresses = tun['Inet' + str(family) + 'Address']
if not addresses:
continue
routes = tun.get('Inet' + str(family) + 'RouteAddress', [])
if routes:
ranges = [ipaddress.ip_network(value, strict=False) for value in routes]
ranges.extend(
ipaddress.ip_interface(value).network
for value in addresses
if ipaddress.ip_interface(value).network.prefixlen < 32
)
else:
bits = 32 if family == 4 else 128
ranges = [
ipaddress.ip_network(
(ipaddress.ip_address(1 << (bits - length)), length)
)
for length in range(8, 0, -1)
]
for value in tun.get('Inet' + str(family) + 'RouteExcludeAddress', []):
exclusion = ipaddress.ip_network(value, strict=False)
remaining = []
for network in ranges:
if network.subnet_of(exclusion):
continue
remaining.extend(
network.address_exclude(exclusion)
if exclusion.subnet_of(network)
else [network]
)
ranges = remaining
result.extend(ipaddress.collapse_addresses(ranges))
return result
def _linuxRuleDelete(flag, rule, table, start, device):
"""Match a pinned native rule's selectors, never delete a priority wildcard."""
allowed = {
'priority',
'src',
'dst',
'table',
'not',
'iif',
'oif',
'goto',
'suppress_prefixlength',
'dport',
'action',
'protocol',
}
priority = int(rule['priority'])
target = str(rule.get('table', ''))
if set(rule) - allowed or target not in ('', str(table), 'main', '254'):
raise RuntimeError('Conflicting Linux policy rule; refusing sing-tun recovery')
if rule.get('iif') not in (None, 'lo', device) or rule.get('oif') is not None:
raise RuntimeError(
'Conflicting Linux interface rule; refusing sing-tun recovery'
)
if 'goto' in rule and rule['goto'] != start + 10:
raise RuntimeError('Conflicting Linux goto rule; refusing sing-tun recovery')
if priority == start + 10 and rule.get('action') != 'nop':
raise RuntimeError(
'Conflicting Linux terminal rule; refusing sing-tun recovery'
)
if rule.get('protocol') not in (None, 0, 'unspec'):
raise RuntimeError(
'Conflicting Linux rule protocol; refusing sing-tun recovery'
)
args = ['ip', flag, 'rule', 'del', 'priority', str(priority)]
if rule.get('not'):
args.append('not')
for key, parameter in (('src', 'from'), ('dst', 'to'), ('iif', 'iif')):
if key in rule:
value = str(rule[key])
if key != 'iif' and value != 'all':
ipaddress.ip_network(value, strict=False)
args.extend([parameter, value])
if 'suppress_prefixlength' in rule:
if rule['suppress_prefixlength'] != 0:
raise RuntimeError('Conflicting Linux suppression rule')
args.extend(['suppress_prefixlength', '0'])
if 'dport' in rule:
if str(rule['dport']) not in ('53', '53-53'):
raise RuntimeError('Conflicting Linux DNS rule')
args.extend(['dport', '53'])
if target:
args.extend(['table', target])
if 'goto' in rule:
args.extend(['goto', str(rule['goto'])])
elif rule.get('action') == 'nop':
args.append('nop')
elif not target:
raise RuntimeError('Unrecognized Linux rule; cleanup ownership retained')
return args
class SingTUNHostPlan:
"""Owned by one SingTUN runtime, including any unfinished host worker."""
def __init__(self, configuration, *, platform=PLATFORM):
copied = copy.deepcopy(configuration)
proxy = copied.pop('proxy', None)
self.configuration = prepareSingTUNSettings(copied, platform=platform)
self.hostSettings = self.configuration.pop('host_options')
self.hostSettingsCallers = SingTUNHostSettingsCallers(self.hostSettings)
if proxy is not None:
self.configuration['proxy'] = proxy
self.platform = platform
self.deviceName = ''
self._worker = None
self._done = threading.Event()
self._cancel = threading.Event()
self.error = ''
self._dnsRestore = []
self._table = None
self._ruleStart = None
self._activated = False
self._ranges = []
self._cleanupComplete = False
self.egressIP = ''
def begin(self, method, *args):
"""Schedule bounded host work; the Qt owner polls completion."""
if self._worker is not None and self._worker.is_alive():
raise RuntimeError('sing-tun host worker is already active')
self._done.clear()
self.error = ''
def run():
try:
if not self._cancel.is_set():
getattr(self, method)(*args)
except Exception as ex:
self.error = str(ex)
finally:
self._done.set()
self._worker = threading.Thread(target=run, name='sing-tun-host')
self._worker.start()
@property
def workDone(self):
return self._done.is_set()
def workCompleted(self):
return self.workDone
def drain(self):
"""Retain a worker on timeout; no late DNS mutation can escape cleanup."""
self._cancel.set()
if self._worker is not None:
self._worker.join(6)
if self._worker.is_alive():
raise RuntimeError('sing-tun host preparation is still stopping')
self._worker = None
def prepare(self, addresses):
if SystemRuntime.flatpakID():
raise RuntimeError('sing-tun application TUN is unavailable in Flatpak')
if self.platform == 'Linux':
# The legacy pkexec script does not elevate this spawned Go child.
if os.geteuid() != 0:
raise RuntimeError(
'sing-tun auto-routing requires root on Linux; '
'select tun2socks to use the existing privileged helper'
)
if not shutil.which('resolvectl'):
raise RuntimeError(
'sing-tun DNS management requires systemd-resolved on Linux; select tun2socks on other resolver configurations'
)
try:
_command(['resolvectl', 'status'])
except RuntimeError:
raise RuntimeError(
'sing-tun requires an active systemd-resolved service on Linux'
) from None
elif not SystemRuntime.isAdmin():
raise RuntimeError('sing-tun requires networking administrator privileges')
self.configuration = addSingTUNExclusions(self.configuration, addresses)
self.captureEgress()
tun = self.configuration['tun_options']
names = self._interfaceNames()
name = tun['Name']
if name and name in names:
raise RuntimeError('The requested sing-tun interface already exists')
if not name:
candidates = ['utun' + str(number) for number in range(10, 1025)]
candidates = [value for value in candidates if value not in names]
if not candidates:
raise RuntimeError('No unused sing-tun interface name is available')
name = secrets.choice(candidates)
if self.platform == 'Linux' and len(name) > 15:
raise ValueError('Linux TUN names must be at most 15 characters')
if self.platform == 'Darwin' and (
not name.startswith('utun') or not name[4:].isdigit()
):
raise ValueError('macOS requires a utun device name followed by a number')
tun['Name'] = self.deviceName = name
if self.platform == 'Linux':
rules = sum(
(
json.loads(_command(['ip', flag, '-j', 'rule', 'show']))
for flag in ('-4', '-6')
),
[],
)
routes = sum(
(
json.loads(
_command(['ip', flag, '-j', 'route', 'show', 'table', 'all'])
)
for flag in ('-4', '-6')
),
[],
)
tables = {str(item.get('table')) for item in rules + routes}
priorities = {int(item['priority']) for item in rules}
for _ in range(100):
table = secrets.randbelow(1000000000) + 100000
start = secrets.randbelow(1800) * 11 + 1000
if str(table) not in tables and not any(
start <= value <= start + 10 for value in priorities
):
self._table, self._ruleStart = table, start
break
else:
raise RuntimeError(
'No isolated sing-tun routing identifiers are available'
)
tun['IPRoute2TableIndex'], tun['IPRoute2RuleIndex'] = (
self._table,
self._ruleStart,
)
elif self.platform == 'Darwin':
self._ranges = _routeRanges(tun)
# Upstream replaces EEXIST destinations. Refuse a conflict before
# activation rather than replacing another owner's exact route.
existing = self._darwinRouteSnapshot()
if any(network in {row[0] for row in existing} for network in self._ranges):
raise RuntimeError(
'sing-tun route ranges conflict with existing macOS routes'
)
def markActivated(self):
# Install before process.start: partial native initialization also needs recovery.
self._activated = True
def applyDNS(self, actualName):
if actualName != self.deviceName:
raise RuntimeError('sing-tun reported an unexpected interface name')
tun = self.configuration['tun_options']
dns = self.hostSettingsCallers.userTunAdapterInterfaceDNS() or (
'1.1.1.1' if tun['Inet4Address'] else '2606:4700:4700::1111'
)
address = ipaddress.ip_address(dns)
if not tun['Inet' + str(address.version) + 'Address']:
raise ValueError(
'The configured TUN DNS address requires that address family on the interface'
)
if self._cancel.is_set():
return
if self.platform == 'Windows':
primaryName = self.hostSettingsCallers.userPrimaryAdapterInterfaceName()
primaryIP = self.hostSettingsCallers.userPrimaryAdapterInterfaceIP()
snapshot = json.loads(
_powershell(
'$ErrorActionPreference="Stop"; '
'$items=@(Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object {$_.IPEnabled}); '
'@($items | ForEach-Object { $c=$_; '
'$a=Get-WmiObject Win32_NetworkAdapter -Filter ("Index="+$c.Index); '
'$p=Get-ItemProperty ("HKLM:\\SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters\\Interfaces\\"+$c.SettingID); '
'New-Object PSObject -Property @{Name=$a.NetConnectionID; IP=$c.IPAddress; '
'Static=$p.NameServer; DNS=$c.DNSServerSearchOrder}}) | ConvertTo-Json -Depth 4'
)
)
if isinstance(snapshot, dict):
snapshot = [snapshot]
physical = [
item
for item in snapshot
if item['Name'] != actualName
and (
(primaryName and item['Name'] == primaryName)
or (primaryIP and primaryIP in (item['IP'] or []))
)
]
if not primaryName and not primaryIP:
# The physical default route was captured before native startup.
physical = [
item for item in snapshot if self.egressIP in (item['IP'] or [])
]
if self.hostSettingsCallers.userDisablePrimaryAdapterInterfaceDNS():
if len(physical) != 1:
raise RuntimeError(
'Unable to identify the physical adapter for DNS restoration'
)
item = physical[0]
if not isinstance(item.get('Name'), str) or not item['Name']:
raise RuntimeError(
'Unable to snapshot the physical adapter DNS identity'
)
item['DNS'] = [
server
for server in item.get('DNS') or []
if ipaddress.ip_address(server).version == 4
]
# Register restoration before the first host write.
self._dnsRestore.append(('Windows', item))
self._windowsDNS(item['Name'], ['127.0.0.1'])
self._windowsDNS(actualName, [str(address)])
_command(['ipconfig', '/flushdns'])
elif self.platform == 'Darwin':
listing = _command(
['networksetup', '-listallnetworkservices']
).splitlines()[1:]
for service in listing:
if not service or service.startswith('*') or self._cancel.is_set():
continue
original = _command(
['networksetup', '-getdnsservers', service]
).splitlines()
servers = []
for value in original:
try:
servers.append(str(ipaddress.ip_address(value)))
except ValueError:
if "aren't any DNS Servers" not in value:
raise RuntimeError(
'Cannot snapshot macOS DNS configuration'
) from None
self._dnsRestore.append(('Darwin', (service, servers)))
_command(['networksetup', '-setdnsservers', service, str(address)])
elif self.platform == 'Linux':
# Only the new, ephemeral interface is touched; never /etc/resolv.conf.
_command(['resolvectl', 'dns', actualName, str(address)])
_command(['resolvectl', 'domain', actualName, '~.'])
def captureEgress(self):
if self.platform == 'Windows':
gateways = SystemRoutingTable.DEFAULT_GATEWAY_WIN32.findall(
_command(['route', 'print', '0.0.0.0'])
)
if len(gateways) != 1:
raise RuntimeError('Unable to identify the physical default gateway')
self.egressIP = (
self.hostSettingsCallers.userPrimaryAdapterInterfaceIP()
or gateways[0][1]
)
@staticmethod
def _windowsDNS(name, servers):
family = (
'ipv4'
if not servers or ipaddress.ip_address(servers[0]).version == 4
else 'ipv6'
)
if not servers:
_command(
[
'netsh',
'interface',
family,
'set',
'dnsservers',
'name=' + name,
'source=dhcp',
]
)
return
_command(
[
'netsh',
'interface',
family,
'set',
'dnsservers',
'name=' + name,
'source=static',
'address=' + servers[0],
'validate=no',
]
)
for index, server in enumerate(servers[1:], 2):
_command(
[
'netsh',
'interface',
family,
'add',
'dnsservers',
'name=' + name,
'address=' + server,
'index=' + str(index),
'validate=no',
]
)
def _interfaceNames(self):
if self.platform == 'Windows':
names = json.loads(
_powershell(
'@([System.Net.NetworkInformation.NetworkInterface]::GetAllNetworkInterfaces() | ForEach-Object {$_.Name}) | ConvertTo-Json'
)
)
return set(names if isinstance(names, list) else [names] if names else [])
return {name for _, name in socket.if_nameindex()}
def _darwinRouteSnapshot(self):
result = []
for family, name in ((4, 'inet'), (6, 'inet6')):
result.extend(
_darwinRoutes(_command(['netstat', '-rn', '-f', name]), family)
)
return result
def restoreDNS(self):
"""Restore only recorded host DNS, independently of native release."""
errors = []
for platform, snapshot in reversed(self._dnsRestore[:]):
try:
if platform == 'Windows':
self._windowsDNS(
snapshot['Name'], snapshot['DNS'] if snapshot['Static'] else []
)
else:
service, servers = snapshot
_command(
[
'networksetup',
'-setdnsservers',
service,
*(servers or ['Empty']),
]
)
self._dnsRestore.remove((platform, snapshot))
except Exception as ex:
errors.append(str(ex))
if errors:
raise RuntimeError('; '.join(errors))
def cleanup(self, cleanExit):
"""Restore independent DNS even if exact native recovery refuses cleanup."""
errors = []
try:
self.restoreDNS()
except Exception as ex:
errors.append(str(ex))
if self._activated:
try:
self._recoverNative()
except Exception as ex:
errors.append(str(ex))
if errors:
raise RuntimeError('; '.join(errors))
self._activated = False
self._cleanupComplete = True
def finishCleanup(self, cleanExit):
"""Run host restoration outside Qt; retain the worker on a bounded drain."""
if self._cleanupComplete:
return
self._finishCleanupWork('cleanup', cleanExit)
def finishDNSCleanup(self):
"""Restore DNS even when the child cannot be reaped; retain native ownership."""
if self._dnsRestore:
self._finishCleanupWork('restoreDNS')
def _finishCleanupWork(self, method, *args):
if self._worker is None:
# drain() cancelled preparation; restoration must still execute.
self._cancel.clear()
self.begin(method, *args)
self._worker.join(6)
if self._worker.is_alive():
raise RuntimeError('sing-tun host restoration is still running')
self._worker = None
if self.error:
raise RuntimeError(self.error)
def _recoverNative(self):
names = self._interfaceNames()
if self.deviceName in names:
# Closing the exact child should remove its ephemeral TUN. Do not
# destroy a same-named interface whose identity we cannot prove.
raise RuntimeError(
'sing-tun interface remains after child exit; cleanup ownership retained'
)
if self.platform == 'Linux' and self._table is not None:
for flag in ('-4', '-6'):
rules = json.loads(_command(['ip', flag, '-j', 'rule', 'show']))
for rule in rules:
priority = int(rule['priority'])
if not self._ruleStart <= priority <= self._ruleStart + 10:
continue
_command(
_linuxRuleDelete(
flag, rule, self._table, self._ruleStart, self.deviceName
)
)
remaining = json.loads(_command(['ip', flag, '-j', 'rule', 'show']))
if any(
self._ruleStart <= int(rule['priority']) <= self._ruleStart + 10
for rule in remaining
):
raise RuntimeError(
'sing-tun Linux rules remain; cleanup ownership retained'
)
routes = json.loads(
_command(
['ip', flag, '-j', 'route', 'show', 'table', str(self._table)]
)
)
if routes:
# Routes referring to the now-closed ephemeral interface
# should disappear in the kernel; never flush an unrelated table.
raise RuntimeError(
'sing-tun routing table remains; cleanup ownership retained'
)
elif self.platform == 'Darwin':
# Routes may survive a partial native start before routeSet is set.
# Delete only an exact planned destination AND its attempt gateway.
table = self._darwinRouteSnapshot()
for network in self._ranges:
family = '4' if network.version == 4 else '6'
gateway = str(
ipaddress.ip_interface(
self.configuration['tun_options']['Inet' + family + 'Address'][
0
]
).ip
)
if any(
row[0] == network
and row[1].split('%', 1)[0] == gateway
and row[2] == self.deviceName
for row in table
):
_command(
[
'route',
'-n',
'delete',
'-inet' + ('6' if family == '6' else ''),
str(network),
gateway,
]
)
remaining = self._darwinRouteSnapshot()
if any(
row[0] in self._ranges and row[2] == self.deviceName
for row in remaining
):
raise RuntimeError(
'sing-tun macOS routes remain; cleanup ownership retained'
)
+12 -1
View File
@@ -24,7 +24,7 @@ from Furious.Application.DesktopApplication import (
_ExistingInstanceResult,
_SingletonStartupResult,
)
from Furious.Frozenlib import AppBuiltinCommand, PySide6Legacy
from Furious.Frozenlib import AppBuiltinCommand, PySide6Legacy, AppSettings
from Furious.Interface import ApplicationRunner, CoreRuntime
from Furious.Qt import AppQMessageBox
from Furious.Qt.AppStyleSheet import AppStyleSheet
@@ -34,6 +34,8 @@ from Furious.Service.RuntimeLease import RuntimeEventRouter, RuntimeLease
from PySide6 import QtCore
from PySide6.QtNetwork import QLocalServer
from tests.support import isolatedSettings
from types import SimpleNamespace
from unittest import TestCase, mock
@@ -1551,6 +1553,15 @@ class ApplicationLifecycleTransactionTest(TestCase):
class ConnectionStartupTransactionTest(TestCase):
"""Verify one failed attempt releases only its own exact runtimes."""
def setUp(self):
# These fixtures explicitly replace the legacy tun2socks host stage.
self._settings = isolatedSettings()
self._settings.__enter__()
AppSettings.set('ApplicationTUNBackend', 'tun2socks')
def tearDown(self):
self._settings.__exit__(None, None, None)
@staticmethod
def _commitExistingRuntime(manager, runtime):
"""Give one fixture runtime to the manager through the real lease boundary."""
+8 -6
View File
@@ -22,21 +22,19 @@ from __future__ import annotations
from Furious.Interface import CoreRuntime, RuntimeState
from Furious.Plugins import PreparedRuntime, CoreRuntimeStartup
from Furious.Qt.Signals import singleShotWeakly
from Furious.Service.ConnectionManager import (
ConnectionManager,
ConnectionStartStage,
)
from Furious.Service.ConnectionManager import ConnectionManager, ConnectionStartStage
from Furious.Service.DnsResolver import DnsResolutionOperation, DnsResolver
from Furious.Frozenlib import AppSettings
from PySide6 import QtCore, QtNetwork
from tests.support import application, processQtEvents, waitFor
from unittest import TestCase, mock
import importlib
import unittest
from unittest import TestCase, mock
class _Configuration:
"""Provide the endpoint values used by manager-only tests."""
@@ -205,6 +203,8 @@ class ConnectionStartupAsyncTest(TestCase):
def setUp(self):
"""Ensure a Qt application exists for real timer/socket delivery."""
self.app = application()
self._previousTUNBackend = AppSettings.get('ApplicationTUNBackend')
AppSettings.set('ApplicationTUNBackend', 'tun2socks')
self.servers = []
self.managers = []
@@ -219,6 +219,8 @@ class ConnectionStartupAsyncTest(TestCase):
processQtEvents()
AppSettings.set('ApplicationTUNBackend', self._previousTUNBackend)
def _manager(self):
"""Return a manager with host TUN disabled at the test boundary."""
manager = ConnectionManager()
+11 -24
View File
@@ -20,6 +20,7 @@
from __future__ import annotations
from Furious.Backends.ExternalCore import ConfigExternalCore, ExternalCoreProcess
from Furious.Frozenlib import AppSettings
from Furious.Backends.ExternalCore.Plugin import (
ExternalCorePlugin,
ExternalCoreRuntimeFactory,
@@ -41,7 +42,7 @@ from Furious.Service.SubscriptionImporter import (
from PySide6 import QtCore
from tests.support import application, waitFor as waitForQt
from tests.support import application, isolatedSettings, waitFor as waitForQt
import os
import sys
@@ -522,7 +523,10 @@ class ExternalCoreProcessTest(unittest.TestCase):
"""Return one successful process-free fixture launch."""
return None, True
with tempfile.TemporaryDirectory(dir=Path.cwd()) as directory:
with tempfile.TemporaryDirectory(
dir=Path.cwd()
) as directory, isolatedSettings():
AppSettings.set('ApplicationTUNBackend', 'tun2socks')
config = self.configuration([], directory)
config['useApplicationTun2socks'] = True
config['tunRemoteAddress'] = 'actual-server.example.com'
@@ -551,28 +555,11 @@ class ExternalCoreProcessTest(unittest.TestCase):
return_value=registry,
),
mock.patch(
'Furious.Service.ConnectionManager.userDefaultPrimaryGatewayIP',
return_value='192.168.50.1',
),
mock.patch(
'Furious.Service.ConnectionManager.userPrimaryAdapterInterfaceIP',
return_value='192.168.50.20',
),
mock.patch(
'Furious.Service.ConnectionManager.userTcpSendBufferSize',
return_value=1,
),
mock.patch(
'Furious.Service.ConnectionManager.userTcpReceiveBufferSize',
return_value=1,
),
mock.patch(
'Furious.Service.ConnectionManager.userTcpAutoTuning',
return_value='False',
),
mock.patch(
'Furious.Service.ConnectionManager.userBypassTUNAdapterInterfaceIP',
return_value='',
'Furious.Service.ConnectionManager.Storage.UserTUNSettings',
return_value={
'defaultPrimaryGatewayIP': '192.168.50.1',
'primaryAdapterInterfaceIP': '192.168.50.20',
},
),
mock.patch(
'Furious.Service.ConnectionManager.SystemRoutingTable.delete'
+6
View File
@@ -178,11 +178,13 @@ class XrayNativeTUNTest(unittest.TestCase):
return_value=registry,
),
mock.patch('Furious.Service.ConnectionManager.Tun2socks') as tun2socks,
mock.patch('Furious.Service.ConnectionManager.SingTUN') as singTUN,
):
self.assertTrue(manager.start(original, 'Global'))
registry.usesApplicationTun2socks.assert_not_called()
tun2socks.assert_not_called()
singTUN.assert_not_called()
self.assertEqual(manager.runtimeConfiguration, original)
@@ -205,6 +207,7 @@ class XrayNativeTUNTest(unittest.TestCase):
return_value=registry,
),
mock.patch('Furious.Service.ConnectionManager.Tun2socks') as tun2socks,
mock.patch('Furious.Service.ConnectionManager.SingTUN') as singTUN,
):
self.assertFalse(manager.start(original, 'Global'))
@@ -212,6 +215,7 @@ class XrayNativeTUNTest(unittest.TestCase):
registry.usesApplicationTun2socks.assert_not_called()
tun2socks.assert_not_called()
singTUN.assert_not_called()
self.assertFalse(hasattr(manager, 'runtimeConfiguration'))
@@ -407,11 +411,13 @@ class Hysteria2NativeTUNTest(unittest.TestCase):
return_value=registry,
),
mock.patch('Furious.Service.ConnectionManager.Tun2socks') as tun2socks,
mock.patch('Furious.Service.ConnectionManager.SingTUN') as singTUN,
):
self.assertTrue(manager.start(original, 'Global'))
registry.usesApplicationTun2socks.assert_not_called()
tun2socks.assert_not_called()
singTUN.assert_not_called()
self.assertEqual(manager.runtimeConfiguration, original)
+1
View File
@@ -58,6 +58,7 @@ class RepositoryContractTest(unittest.TestCase):
'_UserServersStorage',
'_UserSubsStorage',
'_UserTUNSettingsStorage',
'_UserSingTUNSettingsStorage',
'_UserRoutingsStorage',
):
getattr(Storage, name).cache_clear()