Files
2026-09-19 15:58:53 +08:00

609 lines
18 KiB
Python
Raw Permalink 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) 2024present 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/>.
"""Discover proxy egress IP and approximate location without direct fallback."""
from __future__ import annotations
from Furious.Frozenlib import (
AppBinarySettings,
AppConnectionController,
AppSettings,
registerAppSettings,
)
from Furious.Qt.QtNetwork import AppQNetworkAccessManager
from Furious.Qt.Signals import connectWeakly
from Furious.Repository import Storage
from PySide6 import QtCore
from PySide6.QtNetwork import QNetworkReply, QNetworkRequest
from shiboken6 import isValid
from enum import Enum
from dataclasses import dataclass, replace
from ipaddress import IPv4Address, IPv6Address, ip_address
from typing import Callable
from urllib.parse import quote
import json
import logging
__all__ = [
'PROXY_ENDPOINT_INFO_SETTING',
'EndpointInfo',
'EndpointInfoService',
'EndpointInfoState',
'EndpointLocation',
'ProxyEndpointHttpClient',
]
logger = logging.getLogger(__name__)
_MISSING_REQUEST = object()
PROXY_ENDPOINT_INFO_SETTING = 'ProxyEndpointInformationEnabled'
registerAppSettings(
PROXY_ENDPOINT_INFO_SETTING,
isBinary=True,
default=AppBinarySettings.OFF,
)
class EndpointInfoState(Enum):
"""Describe the connection-aware endpoint lookup state."""
Disabled = 'disabled'
Disconnected = 'disconnected'
Connecting = 'connecting'
Loading = 'loading'
Ready = 'ready'
Failed = 'failed'
@dataclass(frozen=True)
class EndpointLocation:
"""Describe approximate public-IP geolocation returned by a provider."""
countryCode: str = ''
countryName: str = ''
region: str = ''
city: str = ''
latitude: float | None = None
longitude: float | None = None
organization: str = ''
@property
def displayName(self) -> str:
"""Return the compact city/region/country presentation."""
values = []
for value in (self.city, self.region, self.countryName):
value = str(value or '').strip()
if value and value not in values:
values.append(value)
return ', '.join(values)
@dataclass(frozen=True)
class EndpointInfo:
"""Store one active connection's observed egress information."""
ipv4: str = ''
ipv6: str = ''
location: EndpointLocation = EndpointLocation()
ipv4Resolved: bool = False
ipv6Resolved: bool = False
locationResolved: bool = False
@property
def primaryAddress(self) -> str:
"""Return the preferred address used for geolocation."""
return self.ipv4 or self.ipv6
class ProxyEndpointHttpClient(AppQNetworkAccessManager):
"""Issue bounded HTTPS GET requests through one explicitly configured proxy."""
completed = QtCore.Signal(object, object, str)
TimeoutMilliseconds = 5000
def __init__(self, parent=None):
"""Initialize reply tracking under the service-owned network manager."""
super().__init__(parent)
self._pendingRequests = {}
def request(self, url: str, context):
"""Start one bounded request associated with *context*."""
request = QNetworkRequest(QtCore.QUrl(url))
request.setTransferTimeout(self.TimeoutMilliseconds)
request.setAttribute(
QNetworkRequest.Attribute.RedirectPolicyAttribute,
QNetworkRequest.RedirectPolicy.NoLessSafeRedirectPolicy,
)
request.setRawHeader(b'Accept', b'text/plain, application/json')
request.setRawHeader(b'User-Agent', b'Furious endpoint information')
reply = self.get(request)
self._trackReplyContext(reply, self._pendingRequests, context)
connectWeakly(
reply.finished,
self,
'_replyFinished',
sender=reply,
forwardSender=True,
)
@QtCore.Slot(object)
def _replyFinished(self, reply):
"""Consume, publish, and release one completed reply."""
if not isinstance(reply, QNetworkReply):
return
context = self._pendingRequests.pop(reply, _MISSING_REQUEST)
try:
if context is _MISSING_REQUEST:
return
if reply.error() == QNetworkReply.NetworkError.NoError:
data, error = bytes(reply.readAll()), ''
else:
data, error = None, reply.errorString()
self.completed.emit(context, data, error)
finally:
if isValid(reply):
reply.deleteLater()
def cancelAll(self):
"""Abort all connection-specific requests without retaining replies."""
pendingReplies = tuple(self._pendingRequests)
self._pendingRequests.clear()
for reply in pendingReplies:
if isValid(reply):
reply.abort()
# abort() can synchronously notify listeners that destroy this owner.
if isValid(reply):
reply.deleteLater()
@dataclass(frozen=True)
class _IPProvider:
name: str
url: str
parser: Callable[[bytes, int], tuple[str, str]]
def _validatedAddress(value, version: int) -> str:
"""Return a canonical address of *version* or raise ``ValueError``."""
address = ip_address(str(value).strip())
if address.version != version:
raise ValueError(f'expected IPv{version}, got IPv{address.version}')
return str(address)
def _parseCloudflareTrace(data: bytes, version: int) -> tuple[str, str]:
"""Parse Cloudflare's documented key/value trace response."""
values = {}
for line in data.decode('utf-8', 'replace').splitlines():
key, separator, value = line.partition('=')
if separator:
values[key.strip()] = value.strip()
return (
_validatedAddress(values.get('ip', ''), version),
str(values.get('loc', '')).upper(),
)
def _parsePlainAddress(data: bytes, version: int) -> tuple[str, str]:
"""Parse a provider response containing only one public address."""
return _validatedAddress(data.decode('ascii', 'strict').strip(), version), ''
IPV4_PROVIDERS = (
_IPProvider(
'Cloudflare',
'https://1.1.1.1/cdn-cgi/trace',
_parseCloudflareTrace,
),
_IPProvider('ipify', 'https://api4.ipify.org', _parsePlainAddress),
)
IPV6_PROVIDERS = (
_IPProvider(
'Cloudflare',
'https://[2606:4700:4700::1111]/cdn-cgi/trace',
_parseCloudflareTrace,
),
_IPProvider('ipify', 'https://api6.ipify.org', _parsePlainAddress),
)
class EndpointInfoService(QtCore.QObject):
"""Own lazy, per-connection endpoint discovery independently from the page."""
stateChanged, resultChanged, enabledChanged = (
QtCore.Signal(object),
QtCore.Signal(object),
QtCore.Signal(bool),
)
def __init__(
self,
parent=None,
*,
controller=None,
httpClient=None,
proxyResolver=None,
enabled=None,
):
"""Initialize the connection observer and injectable HTTP transport."""
super().__init__(parent)
self.controller = controller or AppConnectionController()
self.httpClient = httpClient or ProxyEndpointHttpClient(self)
self.proxyResolver = proxyResolver or Storage.Extras.UserHttpProxy
self._enabled = (
AppSettings.isStateON_(PROXY_ENDPOINT_INFO_SETTING)
if enabled is None
else bool(enabled)
)
self.state = EndpointInfoState.Disabled
self.result = EndpointInfo()
self._generation = 0
self._pageVisible = False
self._cached = False
self._requestInFlight = False
self._family = 4
self._providerIndex = 0
self._countryHint = ''
self.httpClient.completed.connect(self._requestCompleted)
stateChanged = getattr(self.controller, 'stateChanged', None)
activeProfileChanged = getattr(self.controller, 'activeProfileChanged', None)
if stateChanged is not None:
stateChanged.connect(self._connectionStateChanged)
if activeProfileChanged is not None:
activeProfileChanged.connect(self._activeProfileChanged)
self._syncConnectionState()
@property
def enabled(self) -> bool:
"""Return whether privacy-sensitive endpoint inspection is allowed."""
return self._enabled
@QtCore.Slot(bool)
def setEnabled(self, enabled: bool):
"""Apply endpoint inspection immediately and invalidate disabled work."""
enabled = bool(enabled)
if enabled == self._enabled:
return
self._enabled = enabled
self.enabledChanged.emit(enabled)
if not enabled:
self._invalidate()
self._setState(EndpointInfoState.Disabled)
return
self._syncConnectionState()
def _setState(self, state: EndpointInfoState):
"""Publish state only when it changes."""
if state is self.state:
return
self.state = state
self.stateChanged.emit(state)
def _publishResult(self, result: EndpointInfo):
"""Publish immutable endpoint data."""
self.result = result
self.resultChanged.emit(result)
def _invalidate(self):
"""Invalidate the old connection's cache and pending work."""
self._generation += 1
self._cached = False
self._requestInFlight = False
self._countryHint = ''
self.httpClient.cancelAll()
self._publishResult(EndpointInfo())
def _syncConnectionState(self):
"""Reflect the controller and begin lazy work only while visible."""
if not self._enabled:
self._setState(EndpointInfoState.Disabled)
return
if self.controller.isConnected():
self._setState(EndpointInfoState.Loading)
if self._pageVisible:
self.requestIfNeeded()
elif (
getattr(getattr(self.controller, 'state', None), 'name', '') == 'Connecting'
):
self._setState(EndpointInfoState.Connecting)
else:
self._setState(EndpointInfoState.Disconnected)
@QtCore.Slot(object)
def _connectionStateChanged(self, _state):
"""Invalidate results whenever the runtime connection changes state."""
self._invalidate()
self._syncConnectionState()
@QtCore.Slot(object)
def _activeProfileChanged(self, _profile):
"""Reject late data when the active profile identity changes."""
self._invalidate()
self._syncConnectionState()
def setPageVisible(self, visible: bool):
"""Enable network work only while the owning page can present it."""
self._pageVisible = bool(visible)
if self._pageVisible:
self.requestIfNeeded()
@QtCore.Slot()
def refresh(self):
"""Explicitly replace the current connection's cached observation."""
if not self._enabled or not self.controller.isConnected():
return
self._generation += 1
self._cached = False
self._requestInFlight = False
self._countryHint = ''
self.httpClient.cancelAll()
self._setState(EndpointInfoState.Loading)
self._publishResult(EndpointInfo())
self._startLookup()
def requestIfNeeded(self):
"""Start a lookup only for an uncached visible connection session."""
if (
not self._pageVisible
or not self._enabled
or not self.controller.isConnected()
or self._cached
or self._requestInFlight
):
return
self._startLookup()
def _startLookup(self):
"""Configure the active local HTTP proxy before issuing any request."""
proxy = self.proxyResolver()
if not proxy or not self.httpClient.configureHttpProxy(proxy):
logger.error('endpoint lookup refused because no active HTTP proxy exists')
self._setState(EndpointInfoState.Failed)
return
self._requestInFlight = True
self._family = 4
self._providerIndex = 0
self._countryHint = ''
self._setState(EndpointInfoState.Loading)
self._requestIPProvider()
def _providers(self):
"""Return the provider chain for the current address family."""
return IPV4_PROVIDERS if self._family == 4 else IPV6_PROVIDERS
def _requestIPProvider(self):
"""Request the current provider in the sequential fallback chain."""
providers = self._providers()
if self._providerIndex >= len(providers):
self._finishFamily()
return
provider = providers[self._providerIndex]
self.httpClient.request(
provider.url,
{
'generation': self._generation,
'kind': 'ip',
'family': self._family,
'providerIndex': self._providerIndex,
},
)
@QtCore.Slot(object, object, str)
def _requestCompleted(self, context, data, error):
"""Apply only the response that belongs to the current connection."""
if (
not self._enabled
or not isinstance(context, dict)
or context.get('generation') != self._generation
):
return
if context.get('kind') == 'location':
self._locationCompleted(data, error)
else:
self._ipCompleted(context, data, error)
def _ipCompleted(self, context, data, error):
"""Validate one IP provider response or advance to its fallback."""
family = int(context.get('family', 0))
providerIndex = int(context.get('providerIndex', 0))
providers = IPV4_PROVIDERS if family == 4 else IPV6_PROVIDERS
if family != self._family or providerIndex != self._providerIndex:
return
provider = providers[providerIndex]
try:
if error or not isinstance(data, bytes):
raise ValueError(error or 'empty response')
address, countryCode = provider.parser(data, family)
except Exception as ex:
# Any non-exit exceptions
logger.debug(f'{provider.name} IPv{family} endpoint lookup failed: {ex}')
self._providerIndex += 1
self._requestIPProvider()
return
if countryCode and not self._countryHint:
self._countryHint = countryCode
if family == 4:
self._publishResult(replace(self.result, ipv4=address, ipv4Resolved=True))
else:
self._publishResult(replace(self.result, ipv6=address, ipv6Resolved=True))
self._finishFamily()
def _finishFamily(self):
"""Advance from IPv4 to IPv6, then enrich the observed result."""
if self._family == 4:
if not self.result.ipv4Resolved:
self._publishResult(replace(self.result, ipv4Resolved=True))
self._family = 6
self._providerIndex = 0
self._requestIPProvider()
return
if not self.result.ipv6Resolved:
self._publishResult(replace(self.result, ipv6Resolved=True))
address = self.result.primaryAddress
if not address:
self._finishLookup(EndpointInfoState.Failed)
return
encodedAddress = quote(address, safe='')
self.httpClient.request(
f'https://ipapi.co/{encodedAddress}/json/',
{
'generation': self._generation,
'kind': 'location',
'address': address,
},
)
def _locationCompleted(self, data, error):
"""Validate approximate geolocation without discarding valid IP data."""
location = EndpointLocation(countryCode=self._countryHint)
try:
if error or not isinstance(data, bytes):
raise ValueError(error or 'empty response')
payload = json.loads(data.decode('utf-8'))
if payload.get('error'):
raise ValueError(payload.get('reason') or 'provider error')
observedAddress = _validatedAddress(
payload.get('ip', ''), ip_address(self.result.primaryAddress).version
)
if observedAddress != self.result.primaryAddress:
raise ValueError('geolocation response address does not match request')
latitude, longitude = (
float(payload['latitude']),
float(payload['longitude']),
)
if not -90 <= latitude <= 90 or not -180 <= longitude <= 180:
raise ValueError('geolocation coordinates out of range')
countryCode = (
str(payload.get('country_code') or payload.get('country') or '')
.strip()
.upper()
)
location = EndpointLocation(
countryCode=countryCode or self._countryHint,
countryName=str(payload.get('country_name') or '').strip(),
region=str(payload.get('region') or '').strip(),
city=str(payload.get('city') or '').strip(),
latitude=latitude,
longitude=longitude,
organization=str(payload.get('org') or '').strip(),
)
except Exception as ex:
# Any non-exit exceptions
logger.warning(f'approximate endpoint geolocation failed: {ex}')
self._publishResult(
replace(self.result, location=location, locationResolved=True)
)
self._finishLookup(EndpointInfoState.Ready)
def _finishLookup(self, state: EndpointInfoState):
"""Cache the completed connection result and publish final state."""
if not self.result.locationResolved:
self._publishResult(replace(self.result, locationResolved=True))
self._requestInFlight = False
self._cached = True
self._setState(state)