From 1de70e1a44970c244c727f4fc4432a1bafe9f970 Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Sat, 10 Oct 2026 23:52:34 +0800 Subject: [PATCH] Keep tun2socks authorization responsive Signed-off-by: Loren Eteval --- Furious/Core/AGENTS.md | 3 + Furious/Core/Tun2socks.py | 15 +++ Furious/Service/AGENTS.md | 6 ++ Furious/Service/ConnectionManager.py | 51 +++++----- Furious/Service/Tun2socksHost.py | 125 +++++++++++++++++++++++++ tests/README.md | 4 + tests/test_connection_startup_async.py | 105 ++++++++++++++++++++- 7 files changed, 281 insertions(+), 28 deletions(-) create mode 100644 Furious/Service/Tun2socksHost.py diff --git a/Furious/Core/AGENTS.md b/Furious/Core/AGENTS.md index 8c20f178..1e95292a 100644 --- a/Furious/Core/AGENTS.md +++ b/Furious/Core/AGENTS.md @@ -43,6 +43,9 @@ relative to this source tree's root. disposal must also release monitors and output infrastructure, including for a runtime that was never started. These timers belong to their constructing Qt thread even though the runtime owner is a Python object; child execution and process-watch threads must publish through the owned delivery boundary. + Application tun2socks retains its Linux preparation worker through cancellation. While that worker is active, + `cleanupPending` prevents disposal and replacement; stop polls worker completion before removing its device. + Preparation runs no Qt methods and reads an immutable script rather than live routing/settings state. - Verify invalid target/serialization, failed spawn, early exit, readiness compatibility, burst output bounds/backoff, normal and forced stop, repeated disposal, and absence of residual children, handles, timers, queues, or callbacks. Start with `tests/test_runtime_lifecycle.py` and `tests/test_connection_startup_async.py`; diff --git a/Furious/Core/Tun2socks.py b/Furious/Core/Tun2socks.py index 9ce8fa54..45e0f2a6 100644 --- a/Furious/Core/Tun2socks.py +++ b/Furious/Core/Tun2socks.py @@ -95,6 +95,12 @@ class Tun2socks(MultiprocessingRuntime): ) self.cleanup = None + self.linuxPreparation = None + self._cleanupPending = False + + @property + def cleanupPending(self): + return self._cleanupPending @staticmethod def name() -> str: @@ -115,6 +121,15 @@ class Tun2socks(MultiprocessingRuntime): def stop(self): """Stop the tun2socks.""" + if self.linuxPreparation is not None: + self.linuxPreparation.cancel() + + if not self.linuxPreparation.drain(): + self._cleanupPending = True + return + + self._cleanupPending = False + if PLATFORM == 'Linux': # Stop tunnel first super().stop() diff --git a/Furious/Service/AGENTS.md b/Furious/Service/AGENTS.md index bed5e7f1..c0f0bd38 100644 --- a/Furious/Service/AGENTS.md +++ b/Furious/Service/AGENTS.md @@ -45,6 +45,12 @@ source tree's root. 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. + Linux tun2socks GUI startup runs its privileged setup script in an attempt-owned `Tun2socksHostPreparation` + worker. Its Qt observer has no authorization deadline. Cancellation marks the retained script so late + authorization cannot begin setup; keep the worker and files owned until the helper returns before runtime + disposal or replacement. The synchronous startup compatibility path retains its existing blocking helper. + `tests/test_connection_startup_async.py` checks real progress-timer delivery, cancellation ownership and + device-before-runtime ordering with mocked host commands. - 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 diff --git a/Furious/Service/ConnectionManager.py b/Furious/Service/ConnectionManager.py index c8c2d6eb..64586126 100644 --- a/Furious/Service/ConnectionManager.py +++ b/Furious/Service/ConnectionManager.py @@ -51,6 +51,7 @@ 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.Tun2socksHost import Tun2socksHostPreparation from Furious.Service.RuntimeLease import RuntimeEventRouter, RuntimeLease from PySide6 import QtCore, QtNetwork @@ -1238,34 +1239,30 @@ class ConnectionStartOperation(QtCore.QObject): tempdir = os.environ.get('TMPDIR') if SystemRuntime.flatpakID() else None - with tempfile.NamedTemporaryFile( - mode='w', - encoding='utf-8', - suffix='.sh', - dir=tempdir, - delete=True, - ) as file: - file.write( - '\n'.join( - filter( - bool, - [ - commandBringUpTUN, - commandAddDefaultRoute, - commandBypass, - ], - ) - ) + script = '\n'.join( + filter(bool, [commandBringUpTUN, commandAddDefaultRoute, commandBypass]) + ) + preparation = Tun2socksHostPreparation(script, tempdir) + self._tun.linuxPreparation = preparation + preparation.start() + + self._observeCondition( + preparation.workCompleted, + 'tun2socks host preparation', + '_linuxTunPrepared', + ConnectionStartStage.ApplyingHostNetwork, + 'failed to configure Linux TUN routes', + timeout=None, + ) + + def _linuxTunPrepared(self): + preparation = self._tun.linuxPreparation + + if preparation.error or not preparation.success: + self._fail( + '', preparation.error or 'Linux TUN host preparation was cancelled' ) - file.flush() - - if not SystemRoutingTable.LinuxExecutePrivilegedScript( - file.name, - shell='bash', - ): - self._fail('', 'failed to configure Linux TUN routes') - - return + return self._waitForTunDevice( functools.partial( diff --git a/Furious/Service/Tun2socksHost.py b/Furious/Service/Tun2socksHost.py new file mode 100644 index 00000000..2258bff6 --- /dev/null +++ b/Furious/Service/Tun2socksHost.py @@ -0,0 +1,125 @@ +# 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 . + +"""Own tun2socks's Linux setup script while PolicyKit authorization is pending.""" + +from Furious.Frozenlib import SystemRoutingTable + +from pathlib import Path + +import shlex +import tempfile +import threading + + +class Tun2socksHostPreparation: + """Retain one script and worker until its privileged helper has returned.""" + + def __init__(self, script, directory=None): + self.script = script + self.directory = directory + self.error = '' + self.success = False + self._worker = None + self._cancelled = threading.Event() + self._lock = threading.Lock() + self._cancelFile = None + self._scriptFile = None + + def start(self): + if self._worker is not None: + raise RuntimeError('tun2socks host preparation was already started') + + self._worker = threading.Thread(target=self._prepare, name='tun2socks-host') + + try: + self._worker.start() + except BaseException: + self._worker = None + raise + + def _prepare(self): + try: + with tempfile.NamedTemporaryFile( + mode='w', + encoding='utf-8', + suffix='.sh', + dir=self.directory, + delete=False, + ) as file: + script = Path(file.name) + cancelled = Path(file.name + '.cancelled') + + with self._lock: + self._scriptFile = script + if self._cancelled.is_set(): + return + self._cancelFile = cancelled + + # A late authorization after cancellation must not configure TUN. + file.write('[ ! -e ' + shlex.quote(str(cancelled)) + ' ] || exit 1\n') + file.write(self.script) + file.flush() + + self.success = SystemRoutingTable.LinuxExecutePrivilegedScript( + str(script), shell='bash' + ) + + if not self.success: + self.error = 'failed to configure Linux TUN routes' + except Exception as ex: + # Any non-exit exceptions + + self.error = str(ex) or type(ex).__name__ + finally: + try: + self._cleanupFiles() + except OSError as ex: + self.error = str(ex) or type(ex).__name__ + + def _cleanupFiles(self): + with self._lock: + if self._cancelFile is not None: + self._cancelFile.unlink(missing_ok=True) + self._cancelFile = None + + if self._scriptFile is not None: + self._scriptFile.unlink(missing_ok=True) + self._scriptFile = None + + def workCompleted(self): + return self._worker is not None and not self._worker.is_alive() + + def cancel(self): + with self._lock: + self._cancelled.set() + + if self._cancelFile is not None: + self._cancelFile.touch() + + def drain(self): + if self._worker is None: + return True + + self._worker.join(0) + + if self._worker.is_alive(): + return False + + self._cleanupFiles() + self._worker = None + return True diff --git a/tests/README.md b/tests/README.md index 0aae8097..8a7565d7 100644 --- a/tests/README.md +++ b/tests/README.md @@ -128,6 +128,10 @@ observer destruction and shutdown preferences. `test_runtime_lifecycle.py` check `test_architecture_refactors.py` checks that real application exit keeps the event loop and main window available until connection cleanup finishes. These tests mock host changes and do not exercise a real PolicyKit agent. +Linux tun2socks GUI startup is also asynchronous while its privileged setup script waits for authorization. +`test_connection_startup_async.py` checks the real progress-bar timer during this wait and verifies that cancellation +retains the worker/script until helper completion, before cleanup admits another connection. Host commands are mocked. + ### Qt behavior, layout, and lifetime | Module | Coverage | diff --git a/tests/test_connection_startup_async.py b/tests/test_connection_startup_async.py index 3a470c20..ac0df234 100644 --- a/tests/test_connection_startup_async.py +++ b/tests/test_connection_startup_async.py @@ -31,6 +31,8 @@ from Furious.Service.ConnectionManager import ( ) from Furious.Service.DnsResolver import DnsResolutionOperation, DnsResolver from Furious.Frozenlib import AppSettings +from Furious.Core.Tun2socks import Tun2socks as Tun2socksRuntime +from Furious.Widget.ConnectionProgressWidget import ConnectionProgressBar from PySide6 import QtCore, QtNetwork @@ -40,11 +42,13 @@ from tests.support import application, processQtEvents, waitFor from unittest import TestCase, mock from types import SimpleNamespace +from pathlib import Path import importlib import io import shlex import unittest +import threading class _Configuration: @@ -1225,6 +1229,98 @@ class ConnectionStartupAsyncTest(TestCase): class LinuxTun2socksScriptTest(TestCase): + def testProgressContinuesDuringAuthorizationAndCancellationKeepsWorkerOwned(self): + """A real progress timer advances while the helper waits off the GUI thread.""" + application() + module = importlib.import_module('Furious.Service.ConnectionManager') + runtimeModule = importlib.import_module('Furious.Core.Tun2socks') + + class ProcessFreeTun(Tun2socksRuntime): + def start(self): + raise AssertionError('a cancelled startup must not start forwarding') + + entered, released = threading.Event(), threading.Event() + guiThread = threading.get_ident() + scriptPath = [] + primary = _Runtime() + registry = _Registry([PreparedRuntime(primary)]) + manager = ConnectionManager() + manager._prepareTUNPolicy = mock.Mock(return_value=(False, True)) + bar = ConnectionProgressBar() + bar.setValue(0) + + def execute(path, **kwargs): + self.assertNotEqual(threading.get_ident(), guiThread) + scriptPath.append(Path(path)) + entered.set() + if not released.wait(5): + raise RuntimeError('fixture authorization was not released') + self.assertTrue(Path(path + '.cancelled').exists()) + return False + + with ( + mock.patch.object(module, 'PLATFORM', 'Linux'), + mock.patch.object(runtimeModule, 'PLATFORM', 'Linux'), + mock.patch.object(module, 'Tun2socks', ProcessFreeTun), + mock.patch.object(module, 'getPluginRegistry', return_value=registry), + mock.patch.object(module.SystemRoutingTable, 'managedRoutes', []), + mock.patch.object( + module.SystemRoutingTable, + 'getDefaultGateway', + return_value=[('192.0.2.254', 'eth0')], + ), + mock.patch.object( + module.SystemRoutingTable, 'LinuxFindTUNDevice', return_value=False + ), + mock.patch.object( + module.SystemRoutingTable, 'LinuxGetIpRoute', return_value='' + ), + mock.patch.object( + module.SystemRoutingTable, + 'LinuxExecutePrivilegedScript', + side_effect=execute, + ), + mock.patch.object(module.SystemRoutingTable, 'LinuxDeleteTUNDevice'), + mock.patch.object(module.SystemRoutingTable, 'deleteRelations'), + mock.patch.object(module.SystemRuntime, 'flatpakID', return_value=''), + mock.patch('sys.excepthook') as qtErrors, + ): + try: + operation = manager.startAsync( + _Configuration(), 'Global', deepcopy=False + ) + bar.start(1) + self.assertTrue(waitFor(entered.is_set)) + self.assertTrue(waitFor(lambda: bar.value() > 0)) + self.assertFalse(operation._terminal) + self.assertTrue(scriptPath[0].exists()) + self.assertIsNone(operation._conditionProbe._timeout) + + tun = operation._tun + operation.cancel() + cleanup = manager.stopAsync() + cleanup.start() + self.assertFalse(cleanup._terminal) + self.assertTrue(tun.cleanupPending) + self.assertTrue(scriptPath[0].exists()) + + released.set() + self.assertTrue(waitFor(lambda: cleanup._terminal)) + self.assertFalse(tun.cleanupPending) + self.assertFalse(scriptPath[0].exists()) + self.assertFalse(Path(str(scriptPath[0]) + '.cancelled').exists()) + self.assertEqual(manager._pendingReleases, []) + finally: + released.set() + cleanup = manager.stopAsync() + cleanup.start() + self.assertTrue(waitFor(lambda: cleanup._terminal)) + manager.cleanup() + bar.stop() + bar.deleteLater() + processQtEvents() + qtErrors.assert_not_called() + def testBothStartupPathsQuoteArgumentsWithoutChangingRouteDetection(self): module = importlib.import_module('Furious.Service.ConnectionManager') interface = "eth'$x" @@ -1234,7 +1330,12 @@ class LinuxTun2socksScriptTest(TestCase): for asynchronous in (True, False): for existing in (True, False): with self.subTest(asynchronous=asynchronous, existing=existing): - file = io.StringIO() + + class ScriptFile(io.StringIO): + def __exit__(self, *args): + return False + + file = ScriptFile() file.name = 'tmpfixture.sh' scripts = [] tun = _Runtime() @@ -1290,9 +1391,11 @@ class LinuxTun2socksScriptTest(TestCase): _interface=interface, _tun=tun, _waitForTunDevice=mock.Mock(), + _observeCondition=mock.Mock(), _fail=mock.Mock(), ) ConnectionStartOperation._prepareLinuxTun(operation) + self.assertTrue(waitFor(tun.linuxPreparation.workCompleted)) operation._fail.assert_not_called() else: attempt = SimpleNamespace(