Files
LorenEteval_Furious/tests/test_sing_tun_linux_host.py
T
2026-10-09 20:48:16 +08:00

515 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/>.
"""Verify sing-tun's Linux helpers without changing real host networking."""
from Furious.Models.SingTUN import prepareSingTUNSettings, addSingTUNExclusions
from Furious.Service.SingTUNHost import SingTUNHostPlan
from Furious.Frozenlib.SingTUNRouting import (
SingTUNLinuxRouting,
_linuxRouteRanges,
_linuxRules,
_linuxRuleDelete,
_linuxRuleSelectors,
)
from pathlib import Path
from unittest import mock
import importlib
import ipaddress
import json
import os
import shutil
import shlex
import subprocess
import tempfile
import threading
import unittest
def _bash():
"""Use native Bash or Git Bash, never a Windows WSL launcher."""
if os.name != 'nt':
return shutil.which('bash')
git = shutil.which('git')
if git:
candidate = Path(git).resolve().parent.parent / 'bin' / 'bash.exe'
if candidate.exists():
return str(candidate)
return None
class SingTUNLinuxHostTest(unittest.TestCase):
def setUp(self):
self.module = importlib.import_module('Furious.Frozenlib.SingTUNRouting')
self.tun = prepareSingTUNSettings(
{'tun_options': {'Name': 'utun101', 'Inet6Address': ['fd00::1/64']}}
)['tun_options']
self.host = SingTUNLinuxRouting(self.tun, 123456, 2000, threading.Event())
def tearDown(self):
if self.host._process is not None:
self.host._process.poll.return_value = 0
self.host.drain()
def testHelperCreatesUserOwnedTunAndQuotesOnlyHostArguments(self):
scripts = []
process = mock.Mock()
process.wait.return_value = 0
process.poll.return_value = 0
def launch(arguments, **kwargs):
self.assertEqual(arguments[:2], ['pkexec', 'bash'])
self.assertEqual(kwargs['stdin'], subprocess.DEVNULL)
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'))
return process
link = {'ifname': 'utun101', 'ifindex': 77}
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
), mock.patch.object(
self.module, '_command', return_value=json.dumps([link])
):
self.host.prepare()
self.host.applyDNS('1.1.1.1')
self.assertEqual(self.host.index, 77)
self.assertIn('ip tuntap add dev utun101 mode tun user 1000', scripts[0])
self.assertIn('ip link set dev utun101 mtu 1500', scripts[0])
self.assertIn('ip -6 addr add fd00::1/64 dev utun101', scripts[0])
self.assertIn('ip link set dev utun101 up', scripts[0])
self.assertIn('createdIndex=', scripts[0])
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.assertNotIn('socks5', ''.join(scripts))
self.assertIsNone(self.host._process)
self.assertIsNone(self.host._directory)
bash = _bash()
if bash and Path(bash).exists():
for script in scripts:
checked = subprocess.run(
[bash, '-n', '-c', script], capture_output=True, timeout=5
)
self.assertEqual(
checked.returncode, 0, checked.stderr.decode(errors='replace')
)
def testAuthorizationTimeoutRetainsExactProcessAndCancellationFile(self):
process = mock.Mock()
process.wait.side_effect = subprocess.TimeoutExpired('pkexec', 60)
process.poll.return_value = None
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
)
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())
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.host.drain()
self.assertFalse(directory.exists())
self.assertIsNone(self.host._process)
def testScriptRetainsSuccessfulSetupAndRollsBackFailure(self):
bash = _bash()
if not bash or not Path(bash).exists():
self.skipTest('Bash is unavailable for the isolated script fixture')
realPopen = subprocess.Popen
failures = (
('mtu', 'cancel', 'abandon', 'none')
if os.name != 'nt'
else ('mtu', 'cancel', 'none')
)
for failure in failures:
with self.subTest(
failure=failure
), tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
net = root / 'net'
binDirectory = root / 'bin'
net.mkdir()
binDirectory.mkdir()
ip = binDirectory / 'ip'
# This fixture only creates/removes files under its temporary
# root. No real ip, PolicyKit, TUN, route or DNS command is run.
with ip.open('w', encoding='utf-8', newline='\n') as file:
file.write(
'#!/usr/bin/env bash\n'
'set -eu\n'
'device="$FAKE_NET/utun101"\n'
'case "$1 $2 $3" in\n'
'"tuntap add dev")\n'
' mkdir "$device"\n'
' echo 77 > "$device/ifindex"\n'
' if [ "$FAKE_FAILURE" = cancel ]; then touch "$FAKE_CANCEL"; fi\n'
' if [ "$FAKE_FAILURE" = abandon ]; then\n'
' rm "$FAKE_SCRIPT"\n'
' rmdir "${FAKE_CANCEL%/cancelled}"\n'
' fi\n'
' ;;\n'
'"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'
'"link delete dev")\n'
' rm "$device/ifindex"\n'
' rmdir "$device"\n'
' ;;\n'
'*) exit 90 ;;\n'
'esac\n'
)
ip.chmod(0o755)
def launch(arguments, **kwargs):
self.assertEqual(arguments[:2], ['pkexec', 'bash'])
script = Path(arguments[-1])
text = script.read_text(encoding='utf-8')
text = text.replace('/sys/class/net', net.as_posix())
cancelled = script.parent / 'cancelled'
text = text.replace(str(cancelled), cancelled.as_posix())
text = text.replace(str(script.parent), script.parent.as_posix())
binPath = shlex.quote(binDirectory.as_posix())
if os.name == 'nt':
binPath = '$(cygpath -u ' + binPath + ')'
with script.open('w', encoding='utf-8', newline='\n') as file:
file.write('PATH=' + binPath + ':"$PATH"\n' + text)
environment = os.environ.copy()
environment.update(
FAKE_NET=net.as_posix(),
FAKE_FAILURE=failure,
FAKE_CANCEL=(script.parent / 'cancelled').as_posix(),
FAKE_SCRIPT=script.as_posix(),
)
return realPopen([bash, str(script)], env=environment, **kwargs)
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
), mock.patch.object(
self.module,
'_command',
return_value=json.dumps([{'ifname': 'utun101', 'ifindex': 77}]),
):
if failure == 'none':
self.host.prepare()
self.assertEqual(self.host.index, 77)
else:
with self.assertRaisesRegex(
RuntimeError, 'host operation failed'
):
self.host.prepare()
self.assertEqual((net / 'utun101').exists(), failure == 'none')
self.assertIsNone(self.host._process)
self.assertIsNone(self.host._directory)
def testCancellationBeforeAuthorizationCannotAcquireDevice(self):
scripts = []
process = mock.Mock()
process.wait.return_value = 1
process.poll.return_value = 1
def launch(arguments, **kwargs):
directory = Path(arguments[-1]).parent
self.host.cancelled.set()
self.host.cancel()
self.assertTrue((directory / 'cancelled').exists())
scripts.append(Path(arguments[-1]).read_text(encoding='utf-8'))
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, 'authorization was cancelled'):
self.host.execute(
[['ip', 'link', 'set', 'dev', 'utun101', 'up']], preparing=True
)
self.assertLess(scripts[0].index('cancelled'), scripts[0].index('ip link'))
self.assertIsNone(self.host._process)
def testCancelledWorkDoesNotLaunchAnotherAuthorization(self):
self.host.cancelled.set()
with mock.patch.object(self.module.subprocess, 'Popen') as launch:
with self.assertRaisesRegex(RuntimeError, 'preparation was cancelled'):
self.host.execute(
[['ip', 'link', 'set', 'dev', 'utun101', 'up']], preparing=True
)
launch.assert_not_called()
self.assertIsNone(self.host._directory)
def testRootHelperRunsWithoutPolicyKitAndFailedLaunchReleasesScript(self):
with mock.patch.object(
self.module.os, 'geteuid', return_value=0, create=True
), mock.patch.object(
self.module.subprocess, 'Popen', side_effect=OSError('launch')
) as launch:
with self.assertRaisesRegex(RuntimeError, 'Unable to start'):
self.host.execute([['ip', 'link', 'set', 'dev', 'utun101', 'up']])
self.assertEqual(launch.call_args.args[0][0], 'bash')
self.assertIsNone(self.host._directory)
def testRouteRangesKeepCustomPrefixesAndAllRemoteExclusions(self):
configuration = addSingTUNExclusions(
{'tun_options': self.tun}, ['192.0.2.8', '2001:db8::8']
)
ranges = _linuxRouteRanges(configuration['tun_options'])
for address in ('192.0.2.8', '2001:db8::8'):
ip = ipaddress.ip_address(address)
self.assertFalse(
any(ip.version == prefix.version and ip in prefix for prefix in ranges)
)
self.tun['Inet4RouteAddress'] = ['203.0.113.0/24']
ranges = _linuxRouteRanges(self.tun)
self.assertEqual(
[prefix for prefix in ranges if prefix.version == 4],
[ipaddress.ip_network('203.0.113.0/24')],
)
def testIPv6LoopPreventionPrecedesSourceAndDefaultRules(self):
rules = _linuxRules(self.tun, 123456, 2000)
v6 = [command for command in rules if command[1] == '-6']
goto = next(command for command in v6 if 'utun101' in command)
source = next(command for command in v6 if 'fd00::/64' in command)
default = next(command for command in v6 if command[6:] == ['table', '123456'])
self.assertEqual(goto[5], '2001')
self.assertEqual(goto[-2:], ['goto', '2010'])
self.assertEqual(source[5], '2002')
self.assertEqual(default[5], '2004')
def testReplacementInterfaceCannotReceiveDnsOrBeDeleted(self):
self.host.index = 77
link = {'ifname': 'utun101', 'ifindex': 78}
with mock.patch.object(
self.module, '_command', return_value=json.dumps([link])
), mock.patch.object(self.host, 'execute') as execute:
with self.assertRaisesRegex(RuntimeError, 'Conflicting'):
self.host.cleanup()
with self.assertRaisesRegex(RuntimeError, 'Conflicting'):
self.host.applyDNS('1.1.1.1')
execute.assert_not_called()
def testFailedPreparationCannotClaimAnUncapturedInterface(self):
link = {'ifname': 'utun101', 'ifindex': 78}
with mock.patch.object(
self.module, '_command', return_value=json.dumps([link])
), mock.patch.object(self.host, 'execute') as execute:
with self.assertRaisesRegex(RuntimeError, 'identity was not captured'):
self.host.cleanup()
execute.assert_not_called()
self.assertIsNone(self.host.index)
def testIpRouteJsonFlagsAndSeparatePrefixLengthsMatchExactRules(self):
expected = {_linuxRuleSelectors(command) for command in self.host.rules}
records = (
(
'-4',
{
'priority': 2000,
'src': 'all',
'dst': '198.18.0.0',
'dstlen': 15,
'table': 123456,
},
),
(
'-4',
{
'priority': 2002,
'src': 'all',
'not': None,
'table': 'main',
'suppress_prefixlength': 0,
'dport': 53,
'dport_mask': '0xffff',
},
),
(
'-4',
{
'priority': 2003,
'src': 'all',
'not': None,
'iif': 'lo',
'table': 123456,
},
),
('-4', {'priority': 2003, 'src': '0.0.0.0', 'iif': 'lo', 'table': 123456}),
(
'-6',
{
'priority': 2002,
'src': 'fd00::',
'srclen': 64,
'iif': 'lo',
'table': 123456,
},
),
(
'-6',
{
'priority': 2001,
'src': 'all',
'iif': 'utun101',
'goto': 2010,
'unresolved': None,
},
),
(
'-6',
{'priority': 2003, 'src': '0', 'srclen': 1, 'iif': 'lo', 'goto': 2010},
),
('-6', {'priority': 2010, 'src': 'all', 'nop': None}),
)
for flag, record in records:
with self.subTest(rule=record):
command = _linuxRuleDelete(flag, record, 123456, 2000, 'utun101')
self.assertIn(_linuxRuleSelectors(command), expected)
with self.assertRaisesRegex(RuntimeError, 'Conflicting'):
_linuxRuleDelete(
'-4',
{
'priority': 2002,
'not': None,
'dport': 53,
'dport_mask': '0xff00',
'table': 'main',
},
123456,
2000,
'utun101',
)
def testCleanupUsesExactSelectorsAndRetainsRefusedCleanupForRetry(self):
plan = SingTUNHostPlan({}, platform='Linux')
plan._linux = self.host
plan._activated = True
link = {'ifname': 'utun101', 'ifindex': 77}
self.host.index = 77
present = True
rule = {'priority': 2000, 'src': 'all', 'dst': '198.18.0.0/15', 'table': 123456}
def command(arguments):
if arguments == ['ip', '-N', '-j', 'link', 'show']:
return json.dumps([link] if present else [])
if arguments == ['ip', '-4', '-N', '-j', 'rule', 'show']:
return json.dumps([rule] if present else [])
return '[]'
with mock.patch.object(
self.module, '_command', side_effect=command
), mock.patch.object(self.host, 'restoreDNS'), mock.patch.object(
self.host, 'execute', side_effect=RuntimeError('authorization denied')
):
with self.assertRaisesRegex(RuntimeError, 'authorization denied'):
plan.cleanup(False)
self.assertTrue(plan._activated)
self.assertFalse(plan._cleanupComplete)
def release(commands, **kwargs):
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.assertNotIn('flush', str(commands))
present = False
with mock.patch.object(
self.module, '_command', side_effect=command
), mock.patch.object(self.host, 'restoreDNS'), mock.patch.object(
self.host, 'execute', side_effect=release
) as execute:
plan.cleanup(False)
plan.cleanup(True)
execute.assert_called_once()
self.assertFalse(plan._activated)
self.assertTrue(plan._cleanupComplete)
if __name__ == '__main__':
unittest.main()