diff --git a/Furious/Application/DesktopApplication.py b/Furious/Application/DesktopApplication.py index 6947f75c..7908aff6 100644 --- a/Furious/Application/DesktopApplication.py +++ b/Furious/Application/DesktopApplication.py @@ -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.""" diff --git a/Furious/Backends/Configuration.py b/Furious/Backends/Configuration.py index 31902430..bed62639 100644 --- a/Furious/Backends/Configuration.py +++ b/Furious/Backends/Configuration.py @@ -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: diff --git a/Furious/Controllers/SettingsController.py b/Furious/Controllers/SettingsController.py index cff933dc..7527f254 100644 --- a/Furious/Controllers/SettingsController.py +++ b/Furious/Controllers/SettingsController.py @@ -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.""" diff --git a/Furious/Core/AGENTS.md b/Furious/Core/AGENTS.md index 761c44e9..7097b456 100644 --- a/Furious/Core/AGENTS.md +++ b/Furious/Core/AGENTS.md @@ -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. diff --git a/Furious/Core/MultiprocessingRuntime.py b/Furious/Core/MultiprocessingRuntime.py index cce25a71..0c059be7 100644 --- a/Furious/Core/MultiprocessingRuntime.py +++ b/Furious/Core/MultiprocessingRuntime.py @@ -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 diff --git a/Furious/Core/ProcessOutput.py b/Furious/Core/ProcessOutput.py index 36138d83..97b63f46 100644 --- a/Furious/Core/ProcessOutput.py +++ b/Furious/Core/ProcessOutput.py @@ -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, diff --git a/Furious/Core/SingTUN.py b/Furious/Core/SingTUN.py new file mode 100644 index 00000000..174de3cf --- /dev/null +++ b/Furious/Core/SingTUN.py @@ -0,0 +1,373 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""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() diff --git a/Furious/Core/__init__.py b/Furious/Core/__init__.py index de31af37..fb178459 100644 --- a/Furious/Core/__init__.py +++ b/Furious/Core/__init__.py @@ -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', ] diff --git a/Furious/Frozenlib/Constants.py b/Furious/Frozenlib/Constants.py index 5d126f7a..adb7d762 100644 --- a/Furious/Frozenlib/Constants.py +++ b/Furious/Frozenlib/Constants.py @@ -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': diff --git a/Furious/Frozenlib/SocksProxy.py b/Furious/Frozenlib/SocksProxy.py new file mode 100644 index 00000000..9e77fb27 --- /dev/null +++ b/Furious/Frozenlib/SocksProxy.py @@ -0,0 +1,95 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Provide 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) diff --git a/Furious/Frozenlib/__init__.py b/Furious/Frozenlib/__init__.py index 70903f50..61ed1ce6 100644 --- a/Furious/Frozenlib/__init__.py +++ b/Furious/Frozenlib/__init__.py @@ -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', diff --git a/Furious/Models/Configuration.py b/Furious/Models/Configuration.py index 9b0eb8ed..3df0d779 100644 --- a/Furious/Models/Configuration.py +++ b/Furious/Models/Configuration.py @@ -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', '')) diff --git a/Furious/Models/Profile.py b/Furious/Models/Profile.py index a1767103..9ec73e19 100644 --- a/Furious/Models/Profile.py +++ b/Furious/Models/Profile.py @@ -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() diff --git a/Furious/Models/SingTUN.py b/Furious/Models/SingTUN.py new file mode 100644 index 00000000..6e9b795f --- /dev/null +++ b/Furious/Models/SingTUN.py @@ -0,0 +1,364 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""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 diff --git a/Furious/Qt/QtNetwork.py b/Furious/Qt/QtNetwork.py index d63c778b..4dc70c08 100644 --- a/Furious/Qt/QtNetwork.py +++ b/Furious/Qt/QtNetwork.py @@ -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 diff --git a/Furious/Repository/SingTUNSettings.py b/Furious/Repository/SingTUNSettings.py new file mode 100644 index 00000000..a27ab298 --- /dev/null +++ b/Furious/Repository/SingTUNSettings.py @@ -0,0 +1,82 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""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() diff --git a/Furious/Repository/Storage.py b/Furious/Repository/Storage.py index 2e173a23..d964477c 100644 --- a/Furious/Repository/Storage.py +++ b/Furious/Repository/Storage.py @@ -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: diff --git a/Furious/Repository/TunSettings.py b/Furious/Repository/TunSettings.py index 42f927b2..a611ed31 100644 --- a/Furious/Repository/TunSettings.py +++ b/Furious/Repository/TunSettings.py @@ -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') diff --git a/Furious/Service/AGENTS.md b/Furious/Service/AGENTS.md index c78fce0b..96e9929d 100644 --- a/Furious/Service/AGENTS.md +++ b/Furious/Service/AGENTS.md @@ -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. diff --git a/Furious/Service/ConnectionManager.py b/Furious/Service/ConnectionManager.py index ac3fc41f..d3260053 100644 --- a/Furious/Service/ConnectionManager.py +++ b/Furious/Service/ConnectionManager.py @@ -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 diff --git a/Furious/Service/SingTUNHost.py b/Furious/Service/SingTUNHost.py new file mode 100644 index 00000000..2c20fc5c --- /dev/null +++ b/Furious/Service/SingTUNHost.py @@ -0,0 +1,786 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""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' + ) diff --git a/tests/test_architecture_refactors.py b/tests/test_architecture_refactors.py index fc9dca76..55eab784 100644 --- a/tests/test_architecture_refactors.py +++ b/tests/test_architecture_refactors.py @@ -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.""" diff --git a/tests/test_connection_startup_async.py b/tests/test_connection_startup_async.py index 8c858407..3d79cd5b 100644 --- a/tests/test_connection_startup_async.py +++ b/tests/test_connection_startup_async.py @@ -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() diff --git a/tests/test_external_core.py b/tests/test_external_core.py index 14d945c6..1cc1d599 100644 --- a/tests/test_external_core.py +++ b/tests/test_external_core.py @@ -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' diff --git a/tests/test_native_tun_semantics.py b/tests/test_native_tun_semantics.py index e6f663bd..892a3e81 100644 --- a/tests/test_native_tun_semantics.py +++ b/tests/test_native_tun_semantics.py @@ -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) diff --git a/tests/test_repository_contracts.py b/tests/test_repository_contracts.py index 7434b758..dd3a1d59 100644 --- a/tests/test_repository_contracts.py +++ b/tests/test_repository_contracts.py @@ -58,6 +58,7 @@ class RepositoryContractTest(unittest.TestCase): '_UserServersStorage', '_UserSubsStorage', '_UserTUNSettingsStorage', + '_UserSingTUNSettingsStorage', '_UserRoutingsStorage', ): getattr(Storage, name).cache_clear()