Improve sing-tun cleanup lifecycle

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-10-10 20:34:28 +08:00
parent ef0b20153b
commit 6c5cd5c22f
12 changed files with 859 additions and 87 deletions
+3
View File
@@ -290,6 +290,9 @@ class MultiprocessingRuntime(CoreRuntime):
self.stop()
if self.cleanupPending:
return
if self._monitorConnection is not None:
try:
QtCore.QObject.disconnect(self._monitorConnection)
+42 -4
View File
@@ -173,6 +173,7 @@ class SingTUN(MultiprocessingRuntime):
self._started = False
self._cleanExit = False
self._nativeWasReady = False
self._cleanupPending = False
self.cleanup = None
self._configuration = copy.deepcopy(config)
@@ -300,9 +301,32 @@ class SingTUN(MultiprocessingRuntime):
return self._status.get('error', '')
@property
def cleanupPending(self):
return self._cleanupPending
def _pollProcess(self):
self._readStatus()
if (
self._hostPlan is not None
and not self._stopRequested
and not self._exitPublished
):
error = self._hostPlan.runtimeError()
if isinstance(error, str) and error:
self._status = {'state': 'failed', 'error': error}
self._exitPublished = True
self._monitor.stop()
# Keep the live child owned. The connection owner releases it
# and its helper through the normal runtime lease.
self.publishExit(
RuntimeExit(None, RuntimeExitReason.Unexpected, message=error)
)
return
super()._pollProcess()
def interpretExit(self, exitcode, *, requested=False):
@@ -319,8 +343,14 @@ class SingTUN(MultiprocessingRuntime):
return super().interpretExit(exitcode, requested=requested)
def stop(self):
self._stopRequested = True
if self._hostPlan is not None:
self._hostPlan.drain()
if self._hostPlan.drain() is False:
self._cleanupPending = True
return
self._cleanupPending = False
process = self._process
@@ -352,17 +382,22 @@ class SingTUN(MultiprocessingRuntime):
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)
self._cleanupPending = (
self._hostPlan.finishCleanup(
self._cleanExit or not self._started
)
is False
)
else:
# A retained live child prevents native recovery, but host
# DNS snapshots can be restored independently.
self._hostPlan.finishDNSCleanup()
self._cleanupPending = self._hostPlan.finishDNSCleanup() is False
except Exception as ex:
# Any non-exit exceptions
errors.append(str(ex))
if callable(self.cleanup):
if callable(self.cleanup) and not self._cleanupPending:
try:
self.cleanup()
except Exception as ex:
@@ -376,5 +411,8 @@ class SingTUN(MultiprocessingRuntime):
def dispose(self):
super().dispose()
if self.cleanupPending:
return
self._control.close()
self._childControl.close()
+164 -24
View File
@@ -31,6 +31,7 @@ import shlex
import shutil
import subprocess
import tempfile
import time
__all__ = ['SingTUNLinuxRouting']
@@ -67,6 +68,7 @@ def _linuxRuleDelete(flag, rule, table, start, device):
'oif',
'goto',
'suppress_prefixlength',
'suppress_prefixlen',
'dport',
'action',
'protocol',
@@ -158,8 +160,14 @@ def _linuxRuleDelete(flag, rule, table, start, device):
args.extend([parameter, value])
if 'suppress_prefixlength' in rule:
if rule['suppress_prefixlength'] != 0:
suppression = [
rule[key]
for key in ('suppress_prefixlen', 'suppress_prefixlength')
if key in rule
]
if suppression:
if any(type(value) is not int or value != 0 for value in suppression):
raise RuntimeError('Conflicting Linux suppression rule')
args.extend(['suppress_prefixlength', '0'])
@@ -315,8 +323,6 @@ def _linuxRuleSelectors(command):
class SingTUNLinuxRouting:
"""Own the helper process and the persistent TUN created for one attempt."""
AuthorizationTimeout = 60
@staticmethod
def checkEnvironment():
"""Check Linux tools and the resolver before host preparation."""
@@ -381,6 +387,8 @@ class SingTUNLinuxRouting:
self.ranges = _linuxRouteRanges(tun)
self._process = None
self._directory = None
self._holding = False
self._ready = False
def cancel(self):
"""Prevent a pending authorization from acquiring more host resources."""
@@ -393,7 +401,24 @@ class SingTUNLinuxRouting:
# The worker already reaped the helper and removed its directory.
pass
def drain(self):
if self._holding and not self._ready:
self._releaseHelper()
def _releaseHelper(self):
"""EOF requests the fixed cleanup script, never an arbitrary command."""
if self._holding:
self._holding = False
if self._process is not None and self._process.stdin is not None:
self._process.stdin.close()
def runtimeError(self):
"""Report loss of an authorized helper while forwarding is active."""
if self._holding and self._ready and self._process.poll() is not None:
return 'sing-tun privileged helper exited unexpectedly'
return ''
def drain(self, *, wait=True):
"""Retain an unfinished helper and its script until all commands exit."""
process = self._process
@@ -402,9 +427,16 @@ class SingTUNLinuxRouting:
self._directory.cleanup()
self._directory = None
return
return True
if self._holding and self._ready and process.poll() is None:
# The authorized helper stays with the live forwarding runtime.
return True
if process.poll() is None:
if not wait:
return False
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
@@ -415,11 +447,17 @@ class SingTUNLinuxRouting:
self._process = None
self._directory.cleanup()
self._directory = None
self._holding = False
self._ready = False
return True
def execute(self, commands, *, preparing=False, guard=False):
def execute(self, commands, *, preparing=False, guard=False, holding=False):
"""Run only argument vectors generated for this attempt, outside Qt."""
self.drain()
if self._process is not None:
raise RuntimeError('sing-tun privileged helper is already active')
if preparing and self.cancelled.is_set():
raise RuntimeError('sing-tun host preparation was cancelled')
@@ -431,6 +469,9 @@ class SingTUNLinuxRouting:
lines = ['set -eu']
if holding:
lines.extend(self._sessionCleanupLines())
if guard:
name = self.tun['Name']
index = shlex.quote('/sys/class/net/' + name + '/ifindex')
@@ -453,13 +494,33 @@ class SingTUNLinuxRouting:
index = shlex.quote('/sys/class/net/' + name + '/ifindex')
lines.append(f'createdIndex=$(cat {index})')
# Roll back device creation if a later setup command fails.
rollback = (
'code=$?; if [ "$code" -ne 0 ] '
f'&& [ "$(cat {index} 2>/dev/null)" = "$createdIndex" ]; then '
'ip link delete dev ' + shlex.quote(name) + '; fi'
)
lines.append('trap ' + shlex.quote(rollback) + ' EXIT')
if not holding:
# Roll back device creation if a later setup command fails.
rollback = (
'code=$?; if [ "$code" -ne 0 ] '
f'&& [ "$(cat {index} 2>/dev/null)" = "$createdIndex" ]; then '
'ip link delete dev ' + shlex.quote(name) + '; fi'
)
lines.append('trap ' + shlex.quote(rollback) + ' EXIT')
if holding and arguments[2:4] == ['rule', 'add']:
lines.append('rulesAdded=$((rulesAdded + 1))')
if holding:
ready = pathlib.Path(directory.name, 'ready')
staging = pathlib.Path(directory.name, 'ready.tmp')
lines.extend(
[
'printf "%s\\n" "$createdIndex" > ' + shlex.quote(str(staging)),
'mv -- '
+ shlex.quote(str(staging))
+ ' '
+ shlex.quote(str(ready)),
# The pipe also closes if Furious crashes. No second
# authorization is needed to restore this attempt's state.
'read -r release || :',
]
)
with tempfile.NamedTemporaryFile(
mode='w',
@@ -479,17 +540,33 @@ class SingTUNLinuxRouting:
try:
self._process = subprocess.Popen(
arguments,
stdin=subprocess.DEVNULL,
stdin=subprocess.PIPE if holding else subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
code = self._process.wait(timeout=self.AuthorizationTimeout)
except subprocess.TimeoutExpired:
# Do not drop a root process that the unprivileged owner cannot kill.
# Its cancellation file and script survive until a later drain succeeds.
self.cancel()
raise RuntimeError('sing-tun privileged helper timed out') from None
self._holding = holding
if holding:
ready = pathlib.Path(directory.name, 'ready')
while self._process.poll() is None:
if self.cancelled.is_set():
self.cancel()
raise RuntimeError('sing-tun host preparation was cancelled')
if ready.exists():
self.index = int(ready.read_text(encoding='utf-8').strip())
self._ready = True
return
time.sleep(0.05)
self._releaseHelper()
code = self._process.wait()
else:
# Match tun2socks: a PolicyKit interaction has no deadline.
code = self._process.wait()
except OSError:
if self._process is None:
directory.cleanup()
@@ -506,7 +583,44 @@ class SingTUNLinuxRouting:
'sing-tun privileged host operation failed or authorization was cancelled'
)
def prepare(self):
def _sessionCleanupLines(self):
"""Pair successful additions with exact deletions in the same helper."""
name = shlex.quote(self.tun['Name'])
index = shlex.quote('/sys/class/net/' + self.tun['Name'] + '/ifindex')
lines = [
'createdIndex=; rulesAdded=0',
'cleanup() {',
'code=$?; trap - EXIT; set +e; failed=0',
]
for number, rule in reversed(list(enumerate(self.rules, 1))):
deletion = rule[:]
deletion[3] = 'del'
command = ' '.join(shlex.quote(str(value)) for value in deletion)
lines.append(
f'if [ "$rulesAdded" -ge {number} ]; then {command} || failed=1; fi'
)
lines.extend(
[
'if [ -n "$createdIndex" ] && [ -e ' + index + ' ]; then',
f'if [ "$(cat {index})" = "$createdIndex" ]; then',
# resolved discards per-link DNS when the link disappears,
# including when DNS application failed partway through.
f'resolvectl revert {name} || failed=1',
f'ip link delete dev {name} || failed=1',
'else failed=1; fi',
'fi',
'[ "$failed" -eq 0 ] || code=1',
'exit "$code"',
'}',
'trap cleanup EXIT',
"trap 'exit 1' HUP INT TERM",
]
)
return lines
def prepare(self, dnsAddress='1.1.1.1'):
name = self.tun['Name']
# Recheck inside the privileged script before asking ip to create a TUN.
@@ -552,8 +666,15 @@ class SingTUNLinuxRouting:
)
commands.extend(self.rules)
commands.extend(
[
['resolvectl', 'dns', name, dnsAddress],
['resolvectl', 'domain', name, '~.'],
]
)
self.execute(commands, preparing=True)
self.execute(commands, preparing=True, holding=True)
self.dnsTouched = True
link = self.link()
@@ -584,7 +705,13 @@ class SingTUNLinuxRouting:
return link
def applyDNS(self, address):
self.link()
if self.link() is None:
raise RuntimeError('sing-tun interface disappeared before DNS confirmation')
if self._holding and self._ready:
# Linux DNS was configured alongside the interface/routes under
# the original authorization. Native readiness only confirms it.
return
self.dnsTouched = True
@@ -601,12 +728,20 @@ class SingTUNLinuxRouting:
if not self.dnsTouched:
return
if self._process is not None:
# Do not release host routes while the forwarding child is alive.
# cleanup() closes the helper pipe after the child is reaped.
raise RuntimeError(
'sing-tun DNS restoration requires stopping the forwarding child'
)
if self.link() is not None:
self.execute([['resolvectl', 'revert', self.tun['Name']]], guard=True)
self.dnsTouched = False
def cleanup(self):
self._releaseHelper()
self.drain()
link = self.link()
@@ -618,6 +753,9 @@ class SingTUNLinuxRouting:
commands = []
expected = set()
if self.dnsTouched and link is not None:
commands.append(['resolvectl', 'revert', self.tun['Name']])
for rule in self.rules:
deletion = rule[:]
deletion[3] = 'del'
@@ -648,6 +786,8 @@ class SingTUNLinuxRouting:
if self.link() is not None:
raise RuntimeError('sing-tun interface remains; cleanup ownership retained')
self.dnsTouched = False
for flag in ('-4', '-6'):
rules = json.loads(_command(['ip', flag, '-N', '-j', 'rule', 'show']))
+8
View File
@@ -200,6 +200,11 @@ class CoreRuntime(ABC):
"""Passively report execution liveness without consuming an exit."""
return self.state is RuntimeState.Alive
@property
def cleanupPending(self) -> bool:
"""Report owned asynchronous cleanup; stop/dispose may be polled again."""
return False
@staticmethod
@abstractmethod
def name() -> str:
@@ -234,6 +239,9 @@ class CoreRuntime(ABC):
):
self.stop()
if self.cleanupPending:
return
self._exitCallback = None
self._disposed = True
self.setState(RuntimeState.Disposed)
+106 -10
View File
@@ -334,7 +334,7 @@ class _RuntimeReadinessProbe(QtCore.QObject):
class _ConditionProbe(QtCore.QObject):
"""Poll one host condition through a bounded parent-owned Qt timer."""
"""Poll one host condition through a parent-owned Qt timer."""
finished = QtCore.Signal(bool)
@@ -344,7 +344,7 @@ class _ConditionProbe(QtCore.QObject):
self._predicate = predicate
self._description = description
self._timeout = max(int(timeout), 1)
self._timeout = None if timeout is None else max(int(timeout), 1)
self._terminal = False
self._elapsed = QtCore.QElapsedTimer()
@@ -383,7 +383,11 @@ class _ConditionProbe(QtCore.QObject):
logger.info(f'find {self._description} success')
self._finish(True)
elif self._elapsed.isValid() and self._elapsed.elapsed() >= self._timeout:
elif (
self._timeout is not None
and self._elapsed.isValid()
and self._elapsed.elapsed() >= self._timeout
):
logger.error(f'find {self._description} failed')
self._finish(False)
@@ -900,11 +904,7 @@ class ConnectionStartOperation(QtCore.QObject):
continuation,
self.stage,
'sing-tun host preparation timed out',
timeout=(
SingTUNHostPlan.LinuxHostOperationTimeout
if PLATFORM == 'Linux'
else 10000
),
timeout=None if PLATFORM == 'Linux' else 10000,
)
def _startSingTUN(self):
@@ -1377,6 +1377,66 @@ class ConnectionStartOperation(QtCore.QObject):
return True
def _cleanupDestroyedStopOperation(reference, *_args):
operation = reference()
if operation is not None:
operation._terminal = True
if operation.manager._activeStopOperation is operation:
operation.manager._activeStopOperation = None
class ConnectionStopOperation(QtCore.QObject):
"""Observe manager-owned cleanup without timing out authorization."""
finished = QtCore.Signal(object, str)
def __init__(self, manager):
super().__init__()
self.manager = manager
self._terminal = False
self._timer = QtCore.QTimer(self)
self._timer.setInterval(100)
connectWeakly(self._timer.timeout, self, '_poll')
self.destroyed.connect(
functools.partial(_cleanupDestroyedStopOperation, weakref.ref(self))
)
def start(self):
if self._terminal or self._timer.isActive():
return
self._timer.start()
self._poll()
def _poll(self):
if self._terminal:
return
try:
complete = self.manager._pollPendingReleases()
except Exception as ex:
# Any non-exit exceptions
if isValid(self):
self._finish(str(ex) or type(ex).__name__)
return
if not isValid(self) or self._terminal:
return
if complete:
self._finish('')
elif any(lease.releaseError for lease in self.manager._pendingReleases):
self._finish('Connection runtime cleanup is incomplete')
def _finish(self, error):
self._terminal = True
self._timer.stop()
if self.manager._activeStopOperation is self:
self.manager._activeStopOperation = None
try:
self.finished.emit(self, error)
finally:
if isValid(self):
self.deleteLater()
class ConnectionManager(Mixins.CleanupOnExit):
"""Coordinate proxy cores, TUN setup, DNS changes, and routing cleanup."""
@@ -1392,6 +1452,7 @@ class ConnectionManager(Mixins.CleanupOnExit):
self._lastStartError = ''
self._startGeneration = 0
self._activeStartOperation = None
self._activeStopOperation = None
@property
def runtimes(self):
@@ -1539,6 +1600,8 @@ class ConnectionManager(Mixins.CleanupOnExit):
third-party plugins and non-interactive callers that rely on historical
synchronous launch semantics.
"""
if self._activeStopOperation is not None and isValid(self._activeStopOperation):
raise RuntimeError('Connection runtime cleanup is incomplete')
self._retryPendingReleases()
self._lastStartError = ''
@@ -1578,6 +1641,8 @@ class ConnectionManager(Mixins.CleanupOnExit):
**kwargs,
):
"""Create and schedule one manager-owned startup transaction."""
if self._activeStopOperation is not None and isValid(self._activeStopOperation):
raise RuntimeError('Connection runtime cleanup is incomplete')
if self._activeStartOperation is not None:
self._activeStartOperation.cancel()
@@ -2180,12 +2245,43 @@ class ConnectionManager(Mixins.CleanupOnExit):
def _retryPendingReleases(self):
"""Keep exact failed resources and refuse replacement until release succeeds."""
if not self._pollPendingReleases():
raise RuntimeError('Connection runtime cleanup is incomplete')
def _pollPendingReleases(self):
self._pendingReleases[:] = [
lease for lease in self._pendingReleases if not lease.release()
]
return not self._pendingReleases
if self._pendingReleases:
raise RuntimeError('Connection runtime cleanup is incomplete')
def stopAsync(self):
"""Keep pending cleanup leases owned and observe their completion in Qt."""
if self._activeStopOperation is not None and isValid(self._activeStopOperation):
return self._activeStopOperation
try:
operation = ConnectionStopOperation(self)
except BaseException:
self._retainStopLeases()
raise
self._activeStopOperation = operation
try:
self._retainStopLeases()
self.cancelStart()
except BaseException:
self._retainStopLeases()
self._activeStopOperation = None
operation.deleteLater()
raise
singleShotWeakly(0, operation, 'start')
return operation
def _retainStopLeases(self):
self._pendingReleases.extend(reversed(self._leases))
self._leases.clear()
for lease in self._pendingReleases:
lease.router.beginRelease()
def cleanup(self):
"""Release resources owned by the core manager."""
+12 -1
View File
@@ -179,6 +179,7 @@ class RuntimeLease:
self.router = router
self.applicationTUN = applicationTUN
self._releaseInProgress = False
self.releaseError = ''
@property
def state(self):
@@ -199,6 +200,7 @@ class RuntimeLease:
self.router.beginRelease()
self._releaseInProgress = True
self.releaseError = ''
stopped, disposed = True, True
@@ -209,29 +211,38 @@ class RuntimeLease:
# Any non-exit exceptions
stopped = False
self.releaseError = str(ex) or type(ex).__name__
logger.error(f'error stopping core runtime: {ex}')
if not stopped or self.runtime.cleanupPending:
# Preserve the whole runtime until stop succeeds. Disposal must
# not implicitly restart a refused authorization in this call.
return False
try:
self.runtime.dispose()
except Exception as ex:
# Any non-exit exceptions
disposed = False
self.releaseError = str(ex) or type(ex).__name__
logger.error(f'error disposing core runtime: {ex}')
if not stopped or not disposed:
if not disposed or self.runtime.cleanupPending:
return False
try:
if self.runtime.isRunning():
self.releaseError = 'core runtime remains alive after disposal'
logger.error('core runtime remains alive after disposal')
return False
except Exception as ex:
# Any non-exit exceptions
self.releaseError = str(ex) or type(ex).__name__
logger.error(f'could not verify core runtime shutdown: {ex}')
return False
+60 -17
View File
@@ -143,8 +143,6 @@ def _routeRanges(tun):
class SingTUNHostPlan:
"""Owned by one SingTUN runtime, including any unfinished host worker."""
LinuxHostOperationTimeout = (SingTUNLinuxRouting.AuthorizationTimeout + 5) * 1000
def __init__(self, configuration, *, platform=PLATFORM):
copied = copy.deepcopy(configuration)
proxy = copied.pop('proxy', None)
@@ -168,9 +166,10 @@ class SingTUNHostPlan:
self._cleanupComplete = False
self.egressIP = ''
self._linux = None
self._cleanupMethod = None
def begin(self, method, *args):
"""Schedule bounded host work; the Qt owner polls completion."""
"""Schedule owned 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')
@@ -184,12 +183,18 @@ class SingTUNHostPlan:
except Exception as ex:
# Any non-exit exceptions
self.error = str(ex)
self.error = str(ex) or type(ex).__name__
finally:
self._done.set()
self._worker = threading.Thread(target=run, name='sing-tun-host')
self._worker.start()
try:
self._worker.start()
except BaseException:
self._worker = None
self._cleanupMethod = None
self._done.set()
raise
@property
def workDone(self):
@@ -198,23 +203,34 @@ class SingTUNHostPlan:
def workCompleted(self):
return self.workDone
def runtimeError(self):
return self._linux.runtimeError() if self._linux is not None else ''
def drain(self):
"""Retain a worker on timeout; no late DNS mutation can escape cleanup."""
if self._cleanupMethod is not None:
# Restoration owns the worker now; never cancel it as preparation.
return True
self._cancel.set()
if self._linux is not None:
self._linux.cancel()
if self._worker is not None:
self._worker.join(6)
self._worker.join(0 if self.platform == 'Linux' else 6)
if self._worker.is_alive():
if self.platform == 'Linux':
return False
raise RuntimeError('sing-tun host preparation is still stopping')
self._worker = None
if self._linux is not None:
self._linux.drain()
return self._linux.drain(wait=self.platform != 'Linux')
return True
def prepare(self, addresses):
if SystemRuntime.flatpakID():
@@ -262,7 +278,17 @@ class SingTUNHostPlan:
self._linux = SingTUNLinuxRouting(tun, table, start, self._cancel)
self._activated = True
self._linux.prepare()
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'
)
self._linux.prepare(str(address))
# Linux host changes belong to the helper; the spawned binding only
# opens and forwards through the persistent TUN owned by this user.
@@ -517,11 +543,14 @@ class SingTUNHostPlan:
raise RuntimeError('; '.join(errors))
def cleanup(self, cleanExit):
"""Restore independent DNS even if exact native recovery refuses cleanup."""
"""Restore host state, with Linux DNS owned by its authorized helper."""
errors = []
try:
self.restoreDNS()
if self._linux is None:
self.restoreDNS()
# Linux's authorized helper restores DNS with its interface/rules
# after native release, through _recoverNative below.
except Exception as ex:
# Any non-exit exceptions
@@ -542,33 +571,47 @@ class SingTUNHostPlan:
self._cleanupComplete = True
def finishCleanup(self, cleanExit):
"""Run host restoration outside Qt; retain the worker on a bounded drain."""
if self._cleanupComplete:
return
"""Schedule Linux restoration and report completion without joining Qt."""
if self._cleanupComplete and self._worker is None:
return True
self._finishCleanupWork('cleanup', cleanExit)
return self._finishCleanupWork('cleanup', cleanExit)
def finishDNSCleanup(self):
"""Restore DNS even when the child cannot be reaped; retain native ownership."""
if self._dnsRestore or (self._linux is not None and self._linux.dnsTouched):
self._finishCleanupWork('restoreDNS')
if (
self._cleanupMethod == 'restoreDNS'
or self._dnsRestore
or (self._linux is not None and self._linux.dnsTouched)
):
return self._finishCleanupWork('restoreDNS')
return True
def _finishCleanupWork(self, method, *args):
if self._worker is None:
# drain() cancelled preparation; restoration must still execute.
self._cancel.clear()
self._cleanupMethod = method
self.begin(method, *args)
self._worker.join(6)
self._worker.join(0 if self.platform == 'Linux' else 6)
if self._worker.is_alive():
if self.platform == 'Linux':
return False
raise RuntimeError('sing-tun host restoration is still running')
completedMethod = self._cleanupMethod
self._worker = None
self._cleanupMethod = None
if self.error:
raise RuntimeError(self.error)
if completedMethod is not None and completedMethod != method:
return self._finishCleanupWork(method, *args)
return True
def _recoverNative(self):
if self._linux is not None:
self._linux.cleanup()
+35
View File
@@ -178,6 +178,41 @@ class ConnectionStartupAsyncTest(TestCase):
self.assertFalse(isValid(probe))
self.assertFalse(isValid(probe._timer))
def testConditionDeadlineCanBeDisabledWithoutChangingOrdinaryTimeouts(self):
"""Only explicitly unbounded observers survive a long pending condition."""
for timeout in (None, 10000):
with self.subTest(timeout=timeout):
owner = QtCore.QObject()
ready, results = [False], []
probe = _ConditionProbe(
lambda: ready[0], 'fixture', timeout=timeout, parent=owner
)
probe.finished.connect(results.append)
try:
probe.start()
probe._elapsed = mock.Mock()
probe._elapsed.isValid.return_value = True
probe._elapsed.elapsed.return_value = 86400000
probe._poll()
if timeout is None:
self.assertEqual(results, [])
self.assertTrue(probe._timer.isActive())
ready[0] = True
probe._poll()
self.assertEqual(results, [True])
else:
self.assertEqual(results, [False])
self.assertFalse(probe._timer.isActive())
finally:
probe.cancel()
deleteQObject(owner)
self.assertFalse(isValid(probe))
self.assertFalse(isValid(probe._timer))
def testNativeDestructionDuringStagesReleasesUncommittedRuntime(self):
"""Qt destruction ends acquisition and rolls back before child deletion."""
for stage in (ConnectionStartStage.Preparing, ConnectionStartStage.Committing):
+1
View File
@@ -541,6 +541,7 @@ class ExternalCoreProcessTest(unittest.TestCase):
dnsResolver.resolve.return_value = (True, [])
tunRuntime = mock.Mock(spec=CoreRuntime)
tunRuntime.cleanupPending = False
tunRuntime.isRunning.return_value = False
manager = NoCoreRuntimeConnectionManager(dnsResolver=dnsResolver)
+36
View File
@@ -32,6 +32,7 @@ from Furious.Service.RuntimeLease import (
from Furious.Service.ConnectionManager import ConnectionManager, _ConnectionStartAttempt
from PySide6 import QtCore
from shiboken6 import isValid, delete as deleteQObject
from tests.support import application, collectAtBoundary, processQtEvents, waitFor
@@ -104,6 +105,41 @@ class RuntimeLifecycleTest(TestCase):
def setUpClass(cls):
cls.app = application()
def testCleanupCompletionObserverCanDestroyItsOperation(self):
"""Parent-owned timers die even if a completion listener deletes the owner."""
for _ in range(25):
manager = ConnectionManager()
runtime = _Runtime()
manager._leases.append(RuntimeLease(runtime, RuntimeEventRouter()))
operation = manager.stopAsync()
operation.finished.connect(lambda current, _error: deleteQObject(current))
with mock.patch('sys.excepthook') as qtErrors:
operation.start()
processQtEvents()
qtErrors.assert_not_called()
self.assertFalse(isValid(operation))
self.assertFalse(isValid(operation._timer))
self.assertIsNone(manager._activeStopOperation)
self.assertEqual(manager._pendingReleases, [])
manager.cleanup()
def testFailedStopObserverConstructionKeepsRuntimesOwnedAndBlocksReplacement(self):
manager = ConnectionManager()
runtime = _Runtime()
lease = RuntimeLease(runtime, RuntimeEventRouter())
manager._leases.append(lease)
with mock.patch(
'Furious.Service.ConnectionManager.ConnectionStopOperation',
side_effect=RuntimeError('observer factory'),
):
with self.assertRaisesRegex(RuntimeError, 'observer factory'):
manager.stopAsync()
self.assertEqual(manager.runtimes, [])
self.assertEqual(manager._pendingReleases, [lease])
self.assertEqual(lease.state, RuntimeLeaseState.Releasing)
self.assertIsNone(manager._activeStopOperation)
manager.cleanup()
def testFailedLeaseReleaseRemainsOwnedUntilSuccessfulRetry(self):
"""Rollback retains failed resources and blocks replacement admission."""
manager = ConnectionManager()
+168 -3
View File
@@ -40,7 +40,7 @@ from Furious.Service.ConnectionManager import (
ConnectionStartStage,
_ConnectionStartAttempt,
)
from Furious.Frozenlib.SingTUNRouting import _linuxRuleDelete
from Furious.Frozenlib.SingTUNRouting import SingTUNLinuxRouting, _linuxRuleDelete
from Furious.Service.SingTUNHost import (
SingTUNHostPlan,
_routeRanges,
@@ -56,7 +56,11 @@ from Furious.Controllers.ConnectionController import (
ConnectionState,
)
from Furious.Qt import AppQDialogButtonBox, gettext as _
from Furious.Service.RuntimeLease import RuntimeEventRouter, RuntimeLeaseState
from Furious.Service.RuntimeLease import (
RuntimeEventRouter,
RuntimeLease,
RuntimeLeaseState,
)
from Furious.Service.LogManager import LogManager, TUN_LOG_CATEGORY
from PySide6 import QtCore
@@ -154,6 +158,7 @@ class _PreparedSing(SingTUN):
self._hostPlan = hostPlan
self._configuration = copy.deepcopy(configuration)
self.alive = False
self._cleanupPending = False
self.nativeReady = False
self.released = False
@@ -1505,6 +1510,104 @@ class SingTUNChildTest(unittest.TestCase):
finally:
runtime.dispose()
def testUnexpectedHelperExitPublishesOnceAndKeepsTheLiveChildOwned(self):
plan = SingTUNHostPlan({}, platform='Linux')
tun = plan.configuration['tun_options']
tun['Name'] = 'utun101'
plan._linux = SingTUNLinuxRouting(tun, 123456, 2000, threading.Event())
helper = mock.Mock()
helper.poll.return_value = 1
plan._linux._process = helper
plan._linux._holding = plan._linux._ready = True
child = mock.Mock()
child.is_alive.return_value = True
events = []
runtime = SingTUN(
{}, hostPlan=plan, exitCallback=lambda owner, event: events.append(event)
)
runtime._process = child
try:
runtime._pollProcess()
runtime._pollProcess()
self.assertEqual(len(events), 1)
self.assertEqual(events[0].reason, RuntimeExitReason.Unexpected)
self.assertIn('helper exited', events[0].message)
self.assertIs(runtime.process, child)
self.assertTrue(runtime.isRunning())
self.assertFalse(runtime.ready)
finally:
runtime._process = None
runtime._hostPlan = None
runtime.dispose()
def testKernelRuleCleanupAllowsSchedulingTheOtherBackend(self):
"""Retry the failed sing-tun lease before admitting a tun2socks attempt."""
module = importlib.import_module('Furious.Service.ConnectionManager')
routing = importlib.import_module('Furious.Frozenlib.SingTUNRouting')
plan = SingTUNHostPlan({}, platform='Linux')
tun = plan.configuration['tun_options']
tun['Name'] = 'utun101'
plan._linux = SingTUNLinuxRouting(tun, 123456, 2000, threading.Event())
plan._linux.index = 77
plan._activated = True
present, refused = [True], [True]
def command(arguments):
if not present[0]:
return '[]'
if arguments == ['ip', '-N', '-j', 'link', 'show']:
return '[{"ifname":"utun101","ifindex":77}]'
if arguments == ['ip', '-4', '-N', '-j', 'rule', 'show']:
return '[{"priority":2001,"src":"all","table":123456,"suppress_prefixlen":0,"protocol":0}]'
return '[]'
def release(commands, **kwargs):
if refused[0]:
raise RuntimeError('fixture refuses cleanup')
self.assertIn('suppress_prefixlength', commands[0])
present[0] = False
with isolatedSettings(), mock.patch.object(
routing, '_command', side_effect=command
), mock.patch.object(
plan._linux, 'execute', side_effect=release
), mock.patch.object(
module.ConnectionStartOperation, 'start'
):
manager = ConnectionManager()
runtime = SingTUN({}, hostPlan=plan)
router = RuntimeEventRouter()
router.attach(runtime, manager)
lease = RuntimeLease(runtime, router, applicationTUN=True)
manager._leases.append(lease)
try:
with self.assertRaisesRegex(RuntimeError, 'cleanup is incomplete'):
manager.stopAll()
self.assertEqual(manager._pendingReleases, [lease])
refused[0] = False
AppSettings.set('ApplicationTUNBackend', 'tun2socks')
cleanup = manager.stopAsync()
cleanup.start()
self.assertTrue(waitFor(lambda: cleanup._terminal))
operation = manager.startAsync(
_Configuration(), 'Global', deepcopy=False
)
self.assertEqual(manager._pendingReleases, [])
self.assertEqual(lease.state, RuntimeLeaseState.Released)
self.assertEqual(operation.attempt.tunBackend, 'tun2socks')
operation.cancel()
finally:
refused[0] = False
if manager._pendingReleases:
cleanup = manager.stopAsync()
cleanup.start()
self.assertTrue(waitFor(lambda: cleanup._terminal))
manager.cleanup()
processQtEvents()
def testFailedHandleCloseStillRestoresHostAndRetainsRetryOwnership(self):
runtime = _ChildRuntime({})
plan = mock.Mock()
@@ -1558,6 +1661,32 @@ class SingTUNChildTest(unittest.TestCase):
class SingTUNHostTest(unittest.TestCase):
def testCleanupWorkerStartFailureCanBeRetried(self):
plan = SingTUNHostPlan({}, platform='Linux')
module = importlib.import_module('Furious.Service.SingTUNHost')
worker = mock.Mock()
worker.start.side_effect = RuntimeError('worker start')
with mock.patch.object(module.threading, 'Thread', return_value=worker):
with self.assertRaisesRegex(RuntimeError, 'worker start'):
plan.finishCleanup(True)
self.assertIsNone(plan._worker)
self.assertIsNone(plan._cleanupMethod)
self.assertTrue(waitFor(lambda: plan.finishCleanup(True)))
self.assertTrue(plan._cleanupComplete)
self.assertIsNone(plan._worker)
def testLinuxRecoveryPollsTheAuthorizationWorkerWithoutBlocking(self):
"""Polling cleanup adds neither a GUI wait nor an authorization deadline."""
for platform in ('Linux', 'Windows'):
with self.subTest(platform=platform):
plan = SingTUNHostPlan({}, platform=platform)
worker = mock.Mock()
plan._worker = worker
worker.is_alive.return_value = False
plan._finishCleanupWork('cleanup', True)
worker.join.assert_called_once_with(0 if platform == 'Linux' else 6)
self.assertIsNone(plan._worker)
def testLinuxResolverPrerequisiteFailsBeforeNativeActivation(self):
module = importlib.import_module('Furious.Service.SingTUNHost')
routingModule = importlib.import_module('Furious.Frozenlib.SingTUNRouting')
@@ -1702,7 +1831,7 @@ class SingTUNHostTest(unittest.TestCase):
) as helper:
plan.prepare(['192.0.2.1'])
helper.assert_called_once_with()
helper.assert_called_once_with('1.1.1.1')
self.assertTrue(plan._activated)
self.assertFalse(plan.configuration['tun_options']['AutoRoute'])
self.assertTrue(plan.configuration['tun_options']['EXP_ExternalConfiguration'])
@@ -1793,6 +1922,42 @@ class SingTUNStartupTest(unittest.TestCase):
def setUpClass(cls):
application()
def testLinuxHostObserverWaitsForAuthorizationWithoutADeadline(self):
"""An open password request is still pending after a simulated day."""
module = importlib.import_module('Furious.Service.ConnectionManager')
with isolatedSettings(), mock.patch.object(
module, 'PLATFORM', 'Linux'
), mock.patch.object(module.ConnectionStartOperation, 'start'), mock.patch(
'sys.excepthook'
) as qtErrors:
manager = ConnectionManager()
operation = manager.startAsync(_Configuration(), 'Global', deepcopy=False)
operation._tun = mock.Mock()
plan = operation._tun._hostPlan
plan.workCompleted.return_value = False
try:
operation._waitSingHost('_startSingTUN')
probe = operation._conditionProbe
probe._elapsed = mock.Mock()
probe._elapsed.isValid.return_value = True
probe._elapsed.elapsed.return_value = 86400000
probe._poll()
self.assertFalse(operation._terminal)
self.assertTrue(probe._timer.isActive())
plan.workCompleted.return_value = True
with mock.patch.object(operation, '_resume') as resume:
probe._poll()
resume.assert_called_once_with('_startSingTUN')
self.assertIsNone(operation._conditionProbe)
self.assertFalse(probe._timer.isActive())
finally:
manager.cleanup()
processQtEvents()
qtErrors.assert_not_called()
def testBothStartupPathsKeepCapturedTUNLogSourceAfterPreferenceChanges(self):
"""Changing the next engine cannot relabel a previously captured attempt."""
module = importlib.import_module('Furious.Service.ConnectionManager')
+224 -28
View File
@@ -76,17 +76,20 @@ class SingTUNLinuxHostTest(unittest.TestCase):
scripts = []
process = mock.Mock()
process.wait.return_value = 0
process.poll.return_value = 0
process.poll.return_value = None
def launch(arguments, **kwargs):
self.assertEqual(arguments[:2], ['pkexec', 'bash'])
self.assertEqual(kwargs['stdin'], subprocess.DEVNULL)
self.assertEqual(kwargs['stdin'], subprocess.PIPE)
self.assertTrue(
Path(arguments[2]).name.startswith(tempfile.gettempprefix())
)
self.assertEqual(Path(arguments[2]).suffix, '.sh')
scripts.append(Path(arguments[2]).read_text(encoding='utf-8'))
Path(arguments[2]).parent.joinpath('ready').write_text(
'77', encoding='utf-8'
)
return process
link = {'ifname': 'utun101', 'ifindex': 77}
@@ -101,6 +104,8 @@ class SingTUNLinuxHostTest(unittest.TestCase):
self.module, '_command', return_value=json.dumps([link])
):
self.host.prepare()
with self.assertRaisesRegex(RuntimeError, 'already active'):
self.host.execute([])
self.host.applyDNS('1.1.1.1')
self.assertEqual(self.host.index, 77)
@@ -113,9 +118,22 @@ class SingTUNLinuxHostTest(unittest.TestCase):
self.assertIn('trap ', scripts[0])
self.assertNotIn('ifalias', ''.join(scripts))
self.assertNotIn('furious-sing-tun-', ''.join(scripts))
self.assertIn('/sys/class/net/utun101/ifindex', scripts[1])
self.assertIn('resolvectl dns utun101 1.1.1.1', scripts[1])
self.assertEqual(len(scripts), 1)
self.assertIn('/sys/class/net/utun101/ifindex', scripts[0])
self.assertIn('resolvectl dns utun101 1.1.1.1', scripts[0])
self.assertNotIn('socks5', ''.join(scripts))
self.assertIs(self.host._process, process)
self.assertIsNotNone(self.host._directory)
# Forwarding stops first; releasing this same helper needs no pkexec.
with mock.patch.object(
self.module, '_command', return_value='[]'
), mock.patch.object(self.module.subprocess, 'Popen') as launch:
self.host.cleanup()
self.host.cleanup()
launch.assert_not_called()
process.stdin.close.assert_called_once()
self.assertIsNone(self.host._process)
self.assertIsNone(self.host._directory)
@@ -130,37 +148,151 @@ class SingTUNLinuxHostTest(unittest.TestCase):
checked.returncode, 0, checked.stderr.decode(errors='replace')
)
def testAuthorizationTimeoutRetainsExactProcessAndCancellationFile(self):
def testRecoveryWaitsForAuthorizationWithoutADeadline(self):
process = mock.Mock()
process.wait.side_effect = subprocess.TimeoutExpired('pkexec', 60)
process.poll.return_value = None
entered, authorized, complete = (
threading.Event(),
threading.Event(),
threading.Event(),
)
errors = []
def wait(timeout=None):
self.assertIsNone(timeout)
entered.set()
if not authorized.wait(5):
raise RuntimeError('fixture authorization was not released')
process.poll.return_value = 0
return 0
process.wait.side_effect = wait
def recover():
try:
self.host.execute([['ip', 'link', 'delete', 'dev', 'utun101']])
except Exception as ex:
# Any non-exit exceptions
errors.append(ex)
finally:
complete.set()
with mock.patch.object(
self.module.os, 'geteuid', return_value=1000, create=True
), mock.patch.object(self.module.subprocess, 'Popen', return_value=process):
with self.assertRaisesRegex(RuntimeError, 'timed out'):
self.host.execute(
[['ip', 'link', 'set', 'dev', 'utun101', 'up']], preparing=True
)
worker = threading.Thread(target=recover)
worker.start()
try:
self.assertTrue(entered.wait(2))
self.assertFalse(complete.is_set())
self.assertIs(self.host._process, process)
self.assertIsNotNone(self.host._directory)
process.terminate.assert_not_called()
process.kill.assert_not_called()
finally:
authorized.set()
worker.join(5)
self.assertFalse(worker.is_alive())
self.assertEqual(errors, [])
process.wait.assert_called_once_with()
self.assertIsNone(self.host._process)
self.assertIsNone(self.host._directory)
def testDelayedAuthorizationIsUsedForSetupDnsAndDisconnect(self):
"""A slow password entry does not create extra authorization requests."""
process = mock.Mock()
process.wait.return_value = 0
process.poll.return_value = None
clock = [0.0]
script = []
def launch(arguments, **kwargs):
script.append(Path(arguments[-1]))
return process
def advance(_seconds):
clock[0] += 3600
if clock[0] >= 86400:
script[0].parent.joinpath('ready').write_text('77', encoding='utf-8')
with mock.patch.object(
self.module.os, 'geteuid', return_value=1000, create=True
), mock.patch.object(
self.module.os, 'getuid', return_value=1000, create=True
), mock.patch.object(
self.module.subprocess, 'Popen', side_effect=launch
) as launchHelper, mock.patch.object(
self.module.time, 'monotonic', side_effect=lambda: clock[0]
), mock.patch.object(
self.module.time, 'sleep', side_effect=advance
), mock.patch.object(
self.module,
'_command',
return_value=json.dumps([{'ifname': 'utun101', 'ifindex': 77}]),
):
self.host.prepare('9.9.9.9')
self.host.applyDNS('9.9.9.9')
self.host.cancel()
self.host.drain()
process.stdin.close.assert_not_called()
with mock.patch.object(self.module, '_command', return_value='[]'):
self.host.cleanup()
self.assertEqual(clock[0], 86400)
launchHelper.assert_called_once()
process.stdin.close.assert_called_once()
self.assertFalse(self.host.dnsTouched)
def testPendingAuthorizationCancellationRetainsHelperUntilItExits(self):
process = mock.Mock()
process.poll.return_value = None
process.wait.side_effect = subprocess.TimeoutExpired('pkexec', 5)
def launch(arguments, **kwargs):
self.host.cancelled.set()
return process
with mock.patch.object(
self.module.os, 'geteuid', return_value=1000, create=True
), mock.patch.object(self.module.subprocess, 'Popen', side_effect=launch):
with self.assertRaisesRegex(RuntimeError, 'preparation was cancelled'):
self.host.execute([], preparing=True, holding=True)
directory = Path(self.host._directory.name)
self.assertIs(self.host._process, process)
self.assertEqual(len(list(directory.glob('*.sh'))), 1)
self.assertTrue((directory / 'cancelled').exists())
process.stdin.close.assert_called_once()
with self.assertRaisesRegex(RuntimeError, 'still running'):
self.host.drain()
self.assertIs(self.host._process, process)
process.terminate.assert_not_called()
process.kill.assert_not_called()
process.poll.return_value = 0
self.assertTrue(directory.exists())
process.poll.return_value = 126
self.host.drain()
self.assertFalse(directory.exists())
self.assertIsNone(self.host._process)
def testLiveForwarderDnsCannotBeReportedAsRestored(self):
"""A refused child reap retains DNS and the authorized helper together."""
plan = SingTUNHostPlan({}, platform='Linux')
plan._linux = self.host
self.host._process = process = mock.Mock()
process.poll.return_value = None
self.host._directory = tempfile.TemporaryDirectory()
self.host._holding = self.host._ready = self.host.dnsTouched = True
with self.assertRaisesRegex(
RuntimeError, 'requires stopping the forwarding child'
):
while not plan.finishDNSCleanup():
self.assertTrue(plan._done.wait(2))
plan._worker.join(2)
self.assertTrue(self.host.dnsTouched)
self.assertIs(self.host._process, process)
process.stdin.close.assert_not_called()
self.host._releaseHelper()
def testScriptRetainsSuccessfulSetupAndRollsBackFailure(self):
bash = _bash()
@@ -171,9 +303,9 @@ class SingTUNLinuxHostTest(unittest.TestCase):
realPopen = subprocess.Popen
failures = (
('mtu', 'cancel', 'abandon', 'none')
('mtu', 'rule', 'dns', 'cancel', 'abandon', 'none', 'eof')
if os.name != 'nt'
else ('mtu', 'cancel', 'none')
else ('mtu', 'rule', 'dns', 'cancel', 'none', 'eof')
)
for failure in failures:
@@ -209,7 +341,16 @@ class SingTUNLinuxHostTest(unittest.TestCase):
'"link set dev")\n'
' if [ "$FAKE_FAILURE" = mtu ] && [ "$5" = mtu ]; then exit 1; fi\n'
' ;;\n'
'"-4 addr add"|"-6 addr add"|"-4 route add"|"-6 route add"|"-4 rule add"|"-6 rule add") ;;\n'
'"-4 rule add"|"-6 rule add")\n'
' count=$(cat "$FAKE_NET/rules" 2>/dev/null || echo 0)\n'
' if [ "$FAKE_FAILURE" = rule ] && [ "$count" = 2 ]; then exit 1; fi\n'
' echo $((count + 1)) > "$FAKE_NET/rules"\n'
' ;;\n'
'"-4 rule del"|"-6 rule del")\n'
' count=$(cat "$FAKE_NET/rules")\n'
' echo $((count - 1)) > "$FAKE_NET/rules"\n'
' ;;\n'
'"-4 addr add"|"-6 addr add"|"-4 route add"|"-6 route add") ;;\n'
'"link delete dev")\n'
' rm "$device/ifindex"\n'
' rmdir "$device"\n'
@@ -219,6 +360,14 @@ class SingTUNLinuxHostTest(unittest.TestCase):
)
ip.chmod(0o755)
resolver = binDirectory / 'resolvectl'
resolver.write_text(
'#!/usr/bin/env bash\n'
'echo "$1" >> "$FAKE_NET/dns"\n'
'if [ "$FAKE_FAILURE" = dns ] && [ "$1" = domain ]; then exit 1; fi\n',
encoding='utf-8',
)
resolver.chmod(0o755)
def launch(arguments, **kwargs):
self.assertEqual(arguments[:2], ['pkexec', 'bash'])
@@ -257,21 +406,40 @@ class SingTUNLinuxHostTest(unittest.TestCase):
), mock.patch.object(
self.module,
'_command',
return_value=json.dumps([{'ifname': 'utun101', 'ifindex': 77}]),
side_effect=lambda args: json.dumps(
[{'ifname': 'utun101', 'ifindex': 77}]
if args == ['ip', '-N', '-j', 'link', 'show']
and (net / 'utun101').exists()
else []
),
):
if failure == 'none':
if failure in ('none', 'eof'):
self.host.prepare()
self.assertEqual(self.host.index, 77)
self.assertTrue((net / 'utun101').exists())
self.assertIsNone(self.host._process.poll())
if failure == 'eof':
# Closing the parent pipe also covers an application
# exit without calling the normal cleanup method.
self.host._releaseHelper()
self.assertEqual(self.host._process.wait(timeout=5), 0)
self.host.cleanup()
else:
with self.assertRaisesRegex(
RuntimeError, 'host operation failed'
):
self.host.prepare()
self.assertEqual((net / 'utun101').exists(), failure == 'none')
self.assertFalse((net / 'utun101').exists())
self.assertIsNone(self.host._process)
self.assertIsNone(self.host._directory)
if (net / 'rules').exists():
self.assertEqual((net / 'rules').read_text().strip(), '0')
if (net / 'dns').exists():
self.assertEqual(
(net / 'dns').read_text().splitlines()[-1], 'revert'
)
def testCancellationBeforeAuthorizationCannotAcquireDevice(self):
scripts = []
@@ -461,6 +629,28 @@ class SingTUNLinuxHostTest(unittest.TestCase):
'utun101',
)
def testKernelSuppressionFieldMatchesExactRule(self):
"""Use iproute2's JSON spelling, which differs from its CLI spelling."""
expected = {_linuxRuleSelectors(command) for command in self.host.rules}
rule = {
'priority': 2001,
'src': 'all',
'table': 123456,
'suppress_prefixlen': 0,
'protocol': 0,
}
command = _linuxRuleDelete('-4', rule, 123456, 2000, 'utun101')
self.assertIn(_linuxRuleSelectors(command), expected)
self.assertIn('suppress_prefixlength', command)
for value in (1, -1, '0'):
with self.subTest(value=value), self.assertRaisesRegex(
RuntimeError, 'Conflicting'
):
_linuxRuleDelete(
'-4', {**rule, 'suppress_prefixlen': value}, 123456, 2000, 'utun101'
)
def testCleanupUsesExactSelectorsAndRetainsRefusedCleanupForRetry(self):
plan = SingTUNHostPlan({}, platform='Linux')
plan._linux = self.host
@@ -468,7 +658,13 @@ class SingTUNLinuxHostTest(unittest.TestCase):
link = {'ifname': 'utun101', 'ifindex': 77}
self.host.index = 77
present = True
rule = {'priority': 2000, 'src': 'all', 'dst': '198.18.0.0/15', 'table': 123456}
rule = {
'priority': 2001,
'src': 'all',
'table': 123456,
'suppress_prefixlen': 0,
'protocol': 0,
}
def command(arguments):
if arguments == ['ip', '-N', '-j', 'link', 'show']:
@@ -492,8 +688,8 @@ class SingTUNLinuxHostTest(unittest.TestCase):
nonlocal present
self.assertTrue(kwargs['guard'])
self.assertEqual(commands[-1], ['ip', 'link', 'delete', 'dev', 'utun101'])
self.assertIn('to', commands[0])
self.assertIn('198.18.0.0/15', commands[0])
self.assertIn('suppress_prefixlength', commands[0])
self.assertIn('123456', commands[0])
self.assertNotIn('flush', str(commands))
present = False