mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-10-13 09:48:10 +03:00
Keep tun2socks authorization responsive
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
@@ -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`;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user