# 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 . """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()