Keep tun2socks authorization responsive

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-10-10 23:52:34 +08:00
parent 1e4c8b6683
commit 1de70e1a44
7 changed files with 281 additions and 28 deletions
+3
View File
@@ -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`;
+15
View File
@@ -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()
+6
View File
@@ -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
+24 -27
View File
@@ -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(
+125
View File
@@ -0,0 +1,125 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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
+4
View File
@@ -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 |
+104 -1
View File
@@ -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(