Files
LorenEteval_Furious/Furious/Service/SingTUNHost.py
T
2026-10-10 20:34:28 +08:00

671 lines
22 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/>.
"""Attempt-local host preparation and recovery for sing-tun."""
from __future__ import annotations
from Furious.Frozenlib import (
PLATFORM,
SystemRuntime,
SystemRoutingTable,
)
from Furious.Frozenlib.SingTUNRouting import SingTUNLinuxRouting, _command
from Furious.Models.SingTUN import (
SingTUNHostSettingsCallers,
addSingTUNExclusions,
prepareSingTUNSettings,
)
import copy
import ipaddress
import json
import secrets
import socket
import threading
import base64
def _powershell(script):
script = (
'[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; '
'$OutputEncoding=[Console]::OutputEncoding; ' + script
)
return _command(
[
'powershell',
'-NoProfile',
'-NonInteractive',
'-EncodedCommand',
base64.b64encode(script.encode('utf-16le')).decode(),
]
)
def _darwinRoutes(text, family):
"""Normalize netstat's abbreviated destinations, never matching substrings."""
result = []
for line in text.splitlines():
fields = line.split()
if len(fields) < 4:
continue
destination = fields[0]
try:
if destination == 'default':
destination = '0.0.0.0/0' if family == 4 else '::/0'
elif family == 4:
address, separator, mask = destination.partition('/')
octets = address.split('.')
destination = (
'.'.join(octets + ['0'] * (4 - len(octets)))
+ '/'
+ (mask if separator else str(8 * len(octets)))
)
network = ipaddress.ip_network(destination, strict=False)
except ValueError:
continue
if network.version == family:
result.append((network, fields[1], fields[3]))
return result
def _routeRanges(tun):
"""Mirror the pinned Darwin BuildAutoRouteRanges for exact conflict checks."""
result = []
for family in (4, 6):
addresses = tun['Inet' + str(family) + 'Address']
if not addresses:
continue
routes = tun.get('Inet' + str(family) + 'RouteAddress', [])
if routes:
ranges = [ipaddress.ip_network(value, strict=False) for value in routes]
ranges.extend(
ipaddress.ip_interface(value).network
for value in addresses
if ipaddress.ip_interface(value).network.prefixlen < 32
)
else:
bits = 32 if family == 4 else 128
ranges = [
ipaddress.ip_network(
(ipaddress.ip_address(1 << (bits - length)), length)
)
for length in range(8, 0, -1)
]
for value in tun.get('Inet' + str(family) + 'RouteExcludeAddress', []):
exclusion = ipaddress.ip_network(value, strict=False)
remaining = []
for network in ranges:
if network.subnet_of(exclusion):
continue
remaining.extend(
network.address_exclude(exclusion)
if exclusion.subnet_of(network)
else [network]
)
ranges = remaining
result.extend(ipaddress.collapse_addresses(ranges))
return result
class SingTUNHostPlan:
"""Owned by one SingTUN runtime, including any unfinished host worker."""
def __init__(self, configuration, *, platform=PLATFORM):
copied = copy.deepcopy(configuration)
proxy = copied.pop('proxy', None)
self.configuration = prepareSingTUNSettings(copied, platform=platform)
self.hostSettings = self.configuration.pop('host_options')
self.hostSettingsCallers = SingTUNHostSettingsCallers(self.hostSettings)
if proxy is not None:
self.configuration['proxy'] = proxy
self.platform = platform
self.deviceName = ''
self._worker = None
self._done = threading.Event()
self._cancel = threading.Event()
self.error = ''
self._dnsRestore = []
self._activated = False
self._ranges = []
self._cleanupComplete = False
self.egressIP = ''
self._linux = None
self._cleanupMethod = None
def begin(self, method, *args):
"""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')
self._done.clear()
self.error = ''
def run():
try:
if not self._cancel.is_set():
getattr(self, method)(*args)
except Exception as ex:
# Any non-exit exceptions
self.error = str(ex) or type(ex).__name__
finally:
self._done.set()
self._worker = threading.Thread(target=run, name='sing-tun-host')
try:
self._worker.start()
except BaseException:
self._worker = None
self._cleanupMethod = None
self._done.set()
raise
@property
def workDone(self):
return self._done.is_set()
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(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:
return self._linux.drain(wait=self.platform != 'Linux')
return True
def prepare(self, addresses):
if SystemRuntime.flatpakID():
raise RuntimeError('sing-tun application TUN is unavailable in Flatpak')
if self.platform == 'Linux':
SingTUNLinuxRouting.checkEnvironment()
elif not SystemRuntime.isAdmin():
raise RuntimeError('sing-tun requires networking administrator privileges')
self.configuration = addSingTUNExclusions(self.configuration, addresses)
self.captureEgress()
tun = self.configuration['tun_options']
names = self._interfaceNames()
name = tun['Name']
if name and name in names:
raise RuntimeError('The requested sing-tun interface already exists')
if not name:
candidates = ['utun' + str(number) for number in range(10, 1025)]
candidates = [value for value in candidates if value not in names]
if not candidates:
raise RuntimeError('No unused sing-tun interface name is available')
name = secrets.choice(candidates)
if self.platform == 'Linux' and len(name) > 15:
raise ValueError('Linux TUN names must be at most 15 characters')
if self.platform == 'Darwin' and (
not name.startswith('utun') or not name[4:].isdigit()
):
raise ValueError('macOS requires a utun device name followed by a number')
tun['Name'] = self.deviceName = name
if self.platform == 'Linux':
table, start = SingTUNLinuxRouting.reserveIdentifiers()
tun['IPRoute2TableIndex'], tun['IPRoute2RuleIndex'] = table, start
self._linux = SingTUNLinuxRouting(tun, table, start, self._cancel)
self._activated = True
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.
tun['AutoRoute'] = False
tun['EXP_ExternalConfiguration'] = True
elif self.platform == 'Darwin':
self._ranges = _routeRanges(tun)
# Upstream replaces EEXIST destinations. Refuse a conflict before
# activation rather than replacing another owner's exact route.
existing = self._darwinRouteSnapshot()
if any(network in {row[0] for row in existing} for network in self._ranges):
raise RuntimeError(
'sing-tun route ranges conflict with existing macOS routes'
)
def markActivated(self):
# Install before process.start: partial native initialization also needs recovery.
self._activated = True
def applyDNS(self, actualName):
if actualName != self.deviceName:
raise RuntimeError('sing-tun reported an unexpected interface name')
tun = self.configuration['tun_options']
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'
)
if self._cancel.is_set():
return
if self.platform == 'Windows':
primaryName = self.hostSettingsCallers.userPrimaryAdapterInterfaceName()
primaryIP = self.hostSettingsCallers.userPrimaryAdapterInterfaceIP()
snapshot = json.loads(
_powershell(
'$ErrorActionPreference="Stop"; '
'$items=@(Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object {$_.IPEnabled}); '
'@($items | ForEach-Object { $c=$_; '
'$a=Get-WmiObject Win32_NetworkAdapter -Filter ("Index="+$c.Index); '
'$p=Get-ItemProperty ("HKLM:\\SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters\\Interfaces\\"+$c.SettingID); '
'New-Object PSObject -Property @{Name=$a.NetConnectionID; IP=$c.IPAddress; '
'Static=$p.NameServer; DNS=$c.DNSServerSearchOrder}}) | ConvertTo-Json -Depth 4'
)
)
if isinstance(snapshot, dict):
snapshot = [snapshot]
physical = [
item
for item in snapshot
if item['Name'] != actualName
and (
(primaryName and item['Name'] == primaryName)
or (primaryIP and primaryIP in (item['IP'] or []))
)
]
if not primaryName and not primaryIP:
# The physical default route was captured before native startup.
physical = [
item for item in snapshot if self.egressIP in (item['IP'] or [])
]
if self.hostSettingsCallers.userDisablePrimaryAdapterInterfaceDNS():
if len(physical) != 1:
raise RuntimeError(
'Unable to identify the physical adapter for DNS restoration'
)
item = physical[0]
if not isinstance(item.get('Name'), str) or not item['Name']:
raise RuntimeError(
'Unable to snapshot the physical adapter DNS identity'
)
item['DNS'] = [
server
for server in item.get('DNS') or []
if ipaddress.ip_address(server).version == 4
]
# Register restoration before the first host write.
self._dnsRestore.append(('Windows', item))
self._windowsDNS(item['Name'], ['127.0.0.1'])
self._windowsDNS(actualName, [str(address)])
_command(['ipconfig', '/flushdns'])
elif self.platform == 'Darwin':
listing = _command(
['networksetup', '-listallnetworkservices']
).splitlines()[1:]
for service in listing:
if not service or service.startswith('*') or self._cancel.is_set():
continue
original = _command(
['networksetup', '-getdnsservers', service]
).splitlines()
servers = []
for value in original:
try:
servers.append(str(ipaddress.ip_address(value)))
except ValueError:
if "aren't any DNS Servers" not in value:
raise RuntimeError(
'Cannot snapshot macOS DNS configuration'
) from None
self._dnsRestore.append(('Darwin', (service, servers)))
_command(['networksetup', '-setdnsservers', service, str(address)])
elif self.platform == 'Linux':
# Only the new, ephemeral interface is touched; never /etc/resolv.conf.
self._linux.applyDNS(str(address))
def captureEgress(self):
if self.platform == 'Windows':
gateways = SystemRoutingTable.DEFAULT_GATEWAY_WIN32.findall(
_command(['route', 'print', '0.0.0.0'])
)
if len(gateways) != 1:
raise RuntimeError('Unable to identify the physical default gateway')
self.egressIP = (
self.hostSettingsCallers.userPrimaryAdapterInterfaceIP()
or gateways[0][1]
)
@staticmethod
def _windowsDNS(name, servers):
family = (
'ipv4'
if not servers or ipaddress.ip_address(servers[0]).version == 4
else 'ipv6'
)
if not servers:
_command(
[
'netsh',
'interface',
family,
'set',
'dnsservers',
'name=' + name,
'source=dhcp',
]
)
return
_command(
[
'netsh',
'interface',
family,
'set',
'dnsservers',
'name=' + name,
'source=static',
'address=' + servers[0],
'validate=no',
]
)
for index, server in enumerate(servers[1:], 2):
_command(
[
'netsh',
'interface',
family,
'add',
'dnsservers',
'name=' + name,
'address=' + server,
'index=' + str(index),
'validate=no',
]
)
def _interfaceNames(self):
if self.platform == 'Windows':
names = json.loads(
_powershell(
'@([System.Net.NetworkInformation.NetworkInterface]::GetAllNetworkInterfaces() | ForEach-Object {$_.Name}) | ConvertTo-Json'
)
)
return set(names if isinstance(names, list) else [names] if names else [])
return {name for _, name in socket.if_nameindex()}
def _darwinRouteSnapshot(self):
result = []
for family, name in ((4, 'inet'), (6, 'inet6')):
result.extend(
_darwinRoutes(_command(['netstat', '-rn', '-f', name]), family)
)
return result
def restoreDNS(self):
"""Restore only recorded host DNS, independently of native release."""
errors = []
if self._linux is not None:
self._linux.restoreDNS()
for platform, snapshot in reversed(self._dnsRestore[:]):
try:
if platform == 'Windows':
self._windowsDNS(
snapshot['Name'], snapshot['DNS'] if snapshot['Static'] else []
)
else:
service, servers = snapshot
_command(
[
'networksetup',
'-setdnsservers',
service,
*(servers or ['Empty']),
]
)
self._dnsRestore.remove((platform, snapshot))
except Exception as ex:
# Any non-exit exceptions
errors.append(str(ex))
if errors:
raise RuntimeError('; '.join(errors))
def cleanup(self, cleanExit):
"""Restore host state, with Linux DNS owned by its authorized helper."""
errors = []
try:
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
errors.append(str(ex))
if self._activated:
try:
self._recoverNative()
except Exception as ex:
# Any non-exit exceptions
errors.append(str(ex))
if errors:
raise RuntimeError('; '.join(errors))
self._activated = False
self._cleanupComplete = True
def finishCleanup(self, cleanExit):
"""Schedule Linux restoration and report completion without joining Qt."""
if self._cleanupComplete and self._worker is None:
return True
return self._finishCleanupWork('cleanup', cleanExit)
def finishDNSCleanup(self):
"""Restore DNS even when the child cannot be reaped; retain native ownership."""
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(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()
return
names = self._interfaceNames()
if self.deviceName in names:
# Closing the exact child should remove its ephemeral TUN. Do not
# destroy a same-named interface whose identity we cannot prove.
raise RuntimeError(
'sing-tun interface remains after child exit; cleanup ownership retained'
)
if self.platform == 'Darwin':
# Routes may survive a partial native start before routeSet is set.
# Delete only an exact planned destination AND its attempt gateway.
table = self._darwinRouteSnapshot()
for network in self._ranges:
family = '4' if network.version == 4 else '6'
gateway = str(
ipaddress.ip_interface(
self.configuration['tun_options']['Inet' + family + 'Address'][
0
]
).ip
)
if any(
row[0] == network
and row[1].split('%', 1)[0] == gateway
and row[2] == self.deviceName
for row in table
):
_command(
[
'route',
'-n',
'delete',
'-inet' + ('6' if family == '6' else ''),
str(network),
gateway,
]
)
remaining = self._darwinRouteSnapshot()
if any(
row[0] in self._ranges and row[2] == self.deviceName
for row in remaining
):
raise RuntimeError(
'sing-tun macOS routes remain; cleanup ownership retained'
)