Introduced Connect Module, which manages HTTPx Clients, solves network/DNS issues, and coordinates Tor Bootstraps. Introduced async & single endpoint full workflows. This version is stable and tested for DNS & Tor problems. Also modified Configuration to be Pydantic, instead of @dataclass_json, and changed the connection type to enums. Further, there's a new enum function to get the new enum types, but the legacy function exists for backwards compatability.
This commit is contained in:
parent
fb43228f2f
commit
ed055f338d
17 changed files with 1275 additions and 136 deletions
|
|
@ -1,6 +1,10 @@
|
||||||
# Major Change Log:
|
# Major Change Log:
|
||||||
|
|
||||||
|
|
||||||
|
# Connect Module & Async
|
||||||
|
### August 3, 2026
|
||||||
|
Introduced Connect Module, which manages HTTPx Clients, solves network/DNS issues, and coordinates Tor Bootstraps. Introduced async & single endpoint full workflows. This version is stable and tested for DNS & Tor problems. Also modified Configuration to be Pydantic, instead of @dataclass_json, and changed the connection type to enums. Further, there's a new enum function to get the new enum types, but the legacy function exists for backwards compatability.
|
||||||
|
|
||||||
# HTTPx Client, ThreadPool, & Tor Management
|
# HTTPx Client, ThreadPool, & Tor Management
|
||||||
### August 2, 2026
|
### August 2, 2026
|
||||||
Elaborate HTTPx feature-rich strategy with HTTPx Client Reuse, Tor Management, Tor bootstrap monitor & retry. HTTPx clients can be managed across modules and reused. There's an elaborate HTTPx retry strategy based on status codes. Tor management tries to use the existing Tor default ports, then checks the Tor config, before finally bootstrapping. There is now a ThreadPool futures executor, which even works with Tor, to batch API requests.
|
Elaborate HTTPx feature-rich strategy with HTTPx Client Reuse, Tor Management, Tor bootstrap monitor & retry. HTTPx clients can be managed across modules and reused. There's an elaborate HTTPx retry strategy based on status codes. Tor management tries to use the existing Tor default ports, then checks the Tor config, before finally bootstrapping. There is now a ThreadPool futures executor, which even works with Tor, to batch API requests.
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,23 @@
|
||||||
from core.Errors import UnknownConnectionTypeError
|
from core.Errors import UnknownConnectionTypeError
|
||||||
from core.models.Configuration import Configuration
|
from core.models.Configuration import Configuration, ConnectionChoice
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
class ConfigurationController:
|
class ConfigurationController:
|
||||||
|
_config: Optional[Configuration] = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get():
|
def get():
|
||||||
return Configuration.get()
|
if ConfigurationController._config is None:
|
||||||
|
ConfigurationController._config = Configuration.get()
|
||||||
|
return ConfigurationController._config
|
||||||
|
|
||||||
|
# return Configuration.get()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def reload_from_disk():
|
||||||
|
ConfigurationController._config = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_or_new():
|
def get_or_new():
|
||||||
|
|
@ -24,16 +33,26 @@ class ConfigurationController:
|
||||||
def get_connection():
|
def get_connection():
|
||||||
|
|
||||||
configuration = ConfigurationController.get()
|
configuration = ConfigurationController.get()
|
||||||
|
return configuration.connection.value
|
||||||
|
|
||||||
if configuration is None or configuration.connection not in ('system', 'tor'):
|
@staticmethod
|
||||||
raise UnknownConnectionTypeError('The preferred connection type could not be determined.')
|
def get_connection_enum():
|
||||||
|
|
||||||
|
configuration = ConfigurationController.get()
|
||||||
return configuration.connection
|
return configuration.connection
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def set_connection(connection: Optional[str] = None):
|
def set_connection(connection_string: Optional[str] = None):
|
||||||
|
|
||||||
configuration = ConfigurationController.get_or_new()
|
configuration = ConfigurationController.get_or_new()
|
||||||
|
|
||||||
|
if connection_string == "tor":
|
||||||
|
connection = ConnectionChoice.TOR
|
||||||
|
elif connection_string == "system":
|
||||||
|
connection = ConnectionChoice.SYSTEM
|
||||||
|
else:
|
||||||
|
raise UnknownConnectionTypeError(f'The choice of {connection_string} is not valid.')
|
||||||
|
|
||||||
configuration.connection = connection
|
configuration.connection = connection
|
||||||
configuration.save()
|
configuration.save()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,113 +1,75 @@
|
||||||
from core.errors.logger import logger
|
from core.errors.logger import logger
|
||||||
|
|
||||||
from core.Constants import Constants
|
from core.Constants import Constants
|
||||||
from core.Helpers import write_atomically
|
from core.Helpers import write_atomically
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from dataclasses_json import dataclass_json, config
|
#######################
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from pydantic import BaseModel, field_serializer, field_validator, ConfigDict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from marshmallow import fields
|
|
||||||
from typing import Optional, Self
|
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
import dataclasses_json
|
from typing import Optional, Self
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
@dataclass_json
|
class ConnectionChoice(str, Enum):
|
||||||
@dataclass
|
TOR = "tor"
|
||||||
class Configuration:
|
SYSTEM = "system"
|
||||||
connection: Optional[str] = field(
|
|
||||||
default=None,
|
class Configuration(BaseModel):
|
||||||
metadata=config(
|
connection: Optional[ConnectionChoice] = None
|
||||||
undefined=dataclasses_json.Undefined.EXCLUDE,
|
auto_sync_enabled: Optional[bool] = None
|
||||||
exclude=lambda value: value is None
|
endpoint_verification_enabled: Optional[bool] = False
|
||||||
)
|
last_synced_at: Optional[datetime] = None
|
||||||
)
|
firewall: Optional[bool] = False
|
||||||
auto_sync_enabled: Optional[bool] = field(
|
dns: Optional[bool] = False
|
||||||
default=None,
|
did_sudo_setup: Optional[bool] = False
|
||||||
metadata=config(
|
|
||||||
undefined=dataclasses_json.Undefined.EXCLUDE,
|
|
||||||
exclude=lambda value: value is None
|
model_config = ConfigDict(
|
||||||
)
|
extra='ignore', # Ignore unknown fields in JSON
|
||||||
)
|
exclude_none=True # Don't serialize None values
|
||||||
endpoint_verification_enabled: Optional[bool] = field(
|
|
||||||
default=False,
|
|
||||||
metadata=config(
|
|
||||||
undefined=dataclasses_json.Undefined.EXCLUDE,
|
|
||||||
exclude=lambda value: value is None
|
|
||||||
)
|
|
||||||
)
|
|
||||||
last_synced_at: Optional[datetime] = field(
|
|
||||||
default=None,
|
|
||||||
metadata=config(
|
|
||||||
encoder=lambda datetime_instance: Configuration._iso_format(datetime_instance),
|
|
||||||
decoder=lambda datetime_string: Configuration._from_iso_format(datetime_string),
|
|
||||||
mm_field=fields.DateTime(format='iso'),
|
|
||||||
undefined=dataclasses_json.Undefined.EXCLUDE,
|
|
||||||
exclude=lambda value: value is None
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
firewall: Optional[bool] = field(
|
@field_validator('last_synced_at', mode='before')
|
||||||
default=False,
|
@classmethod
|
||||||
metadata=config(
|
def parse_datetime(cls, v):
|
||||||
undefined=dataclasses_json.Undefined.EXCLUDE,
|
if isinstance(v, str):
|
||||||
exclude=lambda value: value is None
|
v = v.replace('Z', '+00:00') # Z → +00:00 for parsing
|
||||||
)
|
return v
|
||||||
)
|
|
||||||
|
|
||||||
dns: Optional[bool] = field(
|
@field_serializer('last_synced_at')
|
||||||
default=False,
|
def serialize_datetime(self, value: datetime) -> str:
|
||||||
metadata=config(
|
if value:
|
||||||
undefined=dataclasses_json.Undefined.EXCLUDE,
|
value = value.replace(tzinfo=ZoneInfo('UTC'))
|
||||||
exclude=lambda value: value is None
|
return value.isoformat().replace('+00:00', 'Z') # +00:00 → Z for JSON
|
||||||
)
|
return None
|
||||||
)
|
|
||||||
|
|
||||||
did_sudo_setup: Optional[bool] = field(
|
|
||||||
default=False,
|
|
||||||
metadata=config(
|
|
||||||
undefined=dataclasses_json.Undefined.EXCLUDE,
|
|
||||||
exclude=lambda value: value is None
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def save(self: Self):
|
|
||||||
|
|
||||||
config_file_contents = f'{self.to_json(indent=4)}\n'
|
|
||||||
os.makedirs(Constants.HV_CONFIG_HOME, exist_ok=True)
|
|
||||||
|
|
||||||
config_file_path = f'{Constants.HV_CONFIG_HOME}/config.json'
|
|
||||||
write_atomically(config_file_path, config_file_contents)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get():
|
def get():
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config_file_contents = open(f'{Constants.HV_CONFIG_HOME}/config.json', 'r').read()
|
with open(f'{Constants.HV_CONFIG_HOME}/config.json', 'r') as f:
|
||||||
|
config_file_contents = f.read()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
configuration = json.loads(config_file_contents)
|
configuration_dict = json.loads(config_file_contents)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# noinspection PyUnresolvedReferences
|
return Configuration(**configuration_dict) # Pydantic validates on init
|
||||||
configuration = Configuration.from_dict(configuration)
|
|
||||||
|
|
||||||
return configuration
|
|
||||||
|
|
||||||
@staticmethod
|
def save(self: Self):
|
||||||
def _iso_format(datetime_instance: datetime):
|
config_file_contents = f'{self.model_dump_json(indent=4)}\n'
|
||||||
datetime_instance = datetime_instance.replace(tzinfo=ZoneInfo('UTC'))
|
os.makedirs(Constants.HV_CONFIG_HOME, exist_ok=True)
|
||||||
return datetime.isoformat(datetime_instance).replace('+00:00', 'Z')
|
|
||||||
|
|
||||||
@staticmethod
|
config_file_path = f'{Constants.HV_CONFIG_HOME}/config.json'
|
||||||
def _from_iso_format(datetime_string: str):
|
write_atomically(config_file_path, config_file_contents)
|
||||||
date_string = datetime_string.replace('Z', '+00:00')
|
|
||||||
return datetime.fromisoformat(date_string)
|
|
||||||
|
|
||||||
|
|
||||||
def read_config():
|
def read_config():
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -39,9 +39,11 @@ class ErrorType(Enum):
|
||||||
DNS_PERMANENT = "dns_permanent"
|
DNS_PERMANENT = "dns_permanent"
|
||||||
QUAD9_DNS_RESOLUTION = "quad9_dns_resolution"
|
QUAD9_DNS_RESOLUTION = "quad9_dns_resolution"
|
||||||
NO_INTERNET = "no_internet"
|
NO_INTERNET = "no_internet"
|
||||||
|
CONNECTION_ERROR = "connection_error"
|
||||||
INVALID_INPUT = "invalid_input"
|
INVALID_INPUT = "invalid_input"
|
||||||
PERMISSION_ERROR = "permission_error" # duplicate
|
PERMISSION_ERROR = "permission_error" # duplicate
|
||||||
DEVELOPER_ERROR = "developer_error"
|
DEVELOPER_ERROR = "developer_error"
|
||||||
|
PORT_NOT_LISTENING = "port_not_listening"
|
||||||
UNKNOWN = "unknown"
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -50,6 +52,7 @@ class ApiResponse:
|
||||||
valid: bool
|
valid: bool
|
||||||
error_type: Optional[ErrorType] = None
|
error_type: Optional[ErrorType] = None
|
||||||
data: Optional[Any] = None
|
data: Optional[Any] = None
|
||||||
|
failures: dict = field(default_factory=dict)
|
||||||
message: Optional[str] = None
|
message: Optional[str] = None
|
||||||
backoff_strategy: BackoffStrategy = BackoffStrategy.NO_RETRY
|
backoff_strategy: BackoffStrategy = BackoffStrategy.NO_RETRY
|
||||||
retry_now: bool = False # legacy
|
retry_now: bool = False # legacy
|
||||||
|
|
|
||||||
119
core/services/networking/httpx/async_batch_requests.py
Normal file
119
core/services/networking/httpx/async_batch_requests.py
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
from httpx_socks import AsyncProxyTransport
|
||||||
|
from core.services.networking.httpx.async_request import make_async_request
|
||||||
|
from core.services.networking.api_requests.ApiResponseModel import ApiResponse, ErrorType
|
||||||
|
from core.errors.logger import logger
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
async def create_async_clearweb_client() -> httpx.AsyncClient:
|
||||||
|
"""Create a CLEARWEB AsyncClient ready for API requests."""
|
||||||
|
client = httpx.AsyncClient(http2=True, timeout=30)
|
||||||
|
return client
|
||||||
|
|
||||||
|
async def create_async_tor_client(port: int) -> httpx.AsyncClient:
|
||||||
|
|
||||||
|
logger.info(f"Creating async client on port {port}")
|
||||||
|
|
||||||
|
"""Create a TOR AsyncClient ready for API requests."""
|
||||||
|
transport = AsyncProxyTransport.from_url(f"socks5://127.0.0.1:{port}")
|
||||||
|
client = httpx.AsyncClient(transport=transport, http2=True, timeout=30)
|
||||||
|
|
||||||
|
# for spoofing errors if we don't want to run more checks:
|
||||||
|
# return client
|
||||||
|
try:
|
||||||
|
response = await make_async_request(
|
||||||
|
method="get",
|
||||||
|
url="https://check.torproject.org/api/ip",
|
||||||
|
client=client,
|
||||||
|
)
|
||||||
|
if response.valid:
|
||||||
|
data = response.data
|
||||||
|
is_tor = data.get("IsTor", False)
|
||||||
|
if is_tor:
|
||||||
|
logger.info(f"Tor session initialized successfully on port {port}")
|
||||||
|
return client
|
||||||
|
else:
|
||||||
|
await client.aclose()
|
||||||
|
logger.warning("Tor connectivity check failed: IsTor returned False")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
await client.aclose()
|
||||||
|
logger.error(f"Error with reaching tor verification: {response.error_type}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await client.aclose()
|
||||||
|
logger.error(f"The HTTP call failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_parallel(desired_endpoints: dict, port: int|None = None) -> dict:
|
||||||
|
|
||||||
|
# Tor
|
||||||
|
if port is not None:
|
||||||
|
client = await create_async_tor_client(port)
|
||||||
|
if client is None:
|
||||||
|
return ApiResponse(valid=False, error_type=ErrorType.TOR_NOT_WORKING)
|
||||||
|
|
||||||
|
# clearweb:
|
||||||
|
else:
|
||||||
|
client = await create_async_clearweb_client()
|
||||||
|
|
||||||
|
start_total = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create all coroutines
|
||||||
|
tasks = [
|
||||||
|
make_async_request(method="get", url=url, client=client)
|
||||||
|
for url in desired_endpoints.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Run them concurrently
|
||||||
|
results_list = await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
# Map back to keys
|
||||||
|
results = dict(zip(desired_endpoints.keys(), results_list))
|
||||||
|
|
||||||
|
total_parallel = time.time() - start_total
|
||||||
|
logger.info(f"\nTotal time for all API calls: {total_parallel:.2f}s")
|
||||||
|
|
||||||
|
return ApiResponse(valid=True, data=results)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
def async_parallel(desired_endpoints: dict, port: int|None = None) -> dict:
|
||||||
|
logger.info(f"We got port of {port}")
|
||||||
|
return asyncio.run(_async_parallel(desired_endpoints, port))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# has_failures = any(not result.valid for result in results.values())
|
||||||
|
# return ApiResponse(valid=not has_failures, data=results)
|
||||||
|
|
||||||
|
|
||||||
|
# def format_results_for_return(results):
|
||||||
|
# # Separate working from failing
|
||||||
|
# working_results = {}
|
||||||
|
# failures = {}
|
||||||
|
# for endpoint_key, result in results.items():
|
||||||
|
# if result.valid:
|
||||||
|
# working_results[endpoint_key] = result
|
||||||
|
# else:
|
||||||
|
# print(f"The failures result for {endpoint_key} is {result}")
|
||||||
|
# failures[endpoint_key] = result
|
||||||
|
|
||||||
|
# has_failures = len(failures) > 0
|
||||||
|
|
||||||
|
# if not has_failures:
|
||||||
|
# # All succeeded
|
||||||
|
# return ApiResponse(valid=True, data=results)
|
||||||
|
|
||||||
|
# logger.info(f"These are the failures: {failures}")
|
||||||
|
|
||||||
|
# # Some failed, some passed. We give the dict of success with data. And a list of the failures.
|
||||||
|
# return ApiResponse(valid=False, data=working_results, failures=failures)
|
||||||
176
core/services/networking/httpx/async_request.py
Normal file
176
core/services/networking/httpx/async_request.py
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
from core.services.networking.api_requests.ApiResponseModel import ApiResponse, ErrorType, BackoffStrategy
|
||||||
|
from core.errors.logger import logger
|
||||||
|
from core.services.networking.httpx.classify_response import classify_response
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
import httpx
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import socket
|
||||||
|
from python_socks._errors import ProxyError
|
||||||
|
|
||||||
|
async def make_async_request(
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
client: httpx.Client,
|
||||||
|
payload: Optional[dict] = None,
|
||||||
|
) -> ApiResponse:
|
||||||
|
|
||||||
|
if method == "post" and not payload:
|
||||||
|
return ApiResponse(valid=False, error_type=ErrorType.INVALID_INPUT, message="Can't have a POST request without a payload")
|
||||||
|
|
||||||
|
initial_result = await _make_async_request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
client=client,
|
||||||
|
payload=payload
|
||||||
|
)
|
||||||
|
|
||||||
|
if initial_result.valid:
|
||||||
|
return initial_result
|
||||||
|
|
||||||
|
logger.error(f"{method.upper()} request failed: {initial_result.error_type}")
|
||||||
|
|
||||||
|
# Immediate Retry
|
||||||
|
if initial_result.backoff_strategy == BackoffStrategy.RETRY_IMMEDIATE:
|
||||||
|
time.sleep(3)
|
||||||
|
second_result = await _make_async_request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
client=client,
|
||||||
|
payload=payload
|
||||||
|
)
|
||||||
|
if second_result.valid:
|
||||||
|
return second_result
|
||||||
|
|
||||||
|
if second_result.backoff_strategy == BackoffStrategy.RETRY_IMMEDIATE:
|
||||||
|
logger.error("We are avoiding retrying immediately twice.")
|
||||||
|
second_result.backoff_strategy = BackoffStrategy.RETRY_EXPONENTIAL
|
||||||
|
return second_result
|
||||||
|
|
||||||
|
# Delayed Retry
|
||||||
|
elif initial_result.backoff_strategy == BackoffStrategy.RETRY_EXPONENTIAL:
|
||||||
|
time.sleep(10)
|
||||||
|
second_result = await _make_async_request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
client=client,
|
||||||
|
payload=payload
|
||||||
|
)
|
||||||
|
return second_result
|
||||||
|
|
||||||
|
# Invalid:
|
||||||
|
elif initial_result.backoff_strategy == BackoffStrategy.FIX_CLIENT_SIDE_INFO and initial_result.error_type == ErrorType.INVALID_REQUEST:
|
||||||
|
logger.error("This was classified Invalid. This might be a mistake of doing GET when it's POST")
|
||||||
|
return switch_get_and_post(method=method, url=url, client=client, payload=payload)
|
||||||
|
|
||||||
|
return initial_result
|
||||||
|
|
||||||
|
error_spoofer = 0
|
||||||
|
|
||||||
|
async def _make_async_request(
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
client: httpx.Client,
|
||||||
|
payload: Optional[dict] = None,
|
||||||
|
) -> ApiResponse:
|
||||||
|
logger.debug(f"Executing {method.upper()} to {url}")
|
||||||
|
try:
|
||||||
|
|
||||||
|
if method.lower() == "get":
|
||||||
|
response = await client.get(url)
|
||||||
|
else:
|
||||||
|
response = await client.post(url, json=payload)
|
||||||
|
|
||||||
|
return classify_response(response)
|
||||||
|
|
||||||
|
except httpx.TimeoutException as e:
|
||||||
|
return ApiResponse(
|
||||||
|
valid=False,
|
||||||
|
error_type=ErrorType.NETWORK_ERROR,
|
||||||
|
message=f"Request timeout: {str(e)}",
|
||||||
|
backoff_strategy=BackoffStrategy.RETRY_EXPONENTIAL
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
except httpx.ConnectError as e:
|
||||||
|
logger.error(f"Error: {e}")
|
||||||
|
if isinstance(e.__cause__, socket.gaierror):
|
||||||
|
gaierror = e.__cause__
|
||||||
|
if gaierror.errno in (-3, -11): # EAI_AGAIN
|
||||||
|
# Transient DNS failure
|
||||||
|
return ApiResponse(valid=False, error_type=ErrorType.DNS_TEMPORARY, backoff_strategy=BackoffStrategy.NO_RETRY) # this is going to go to DNS resolver.
|
||||||
|
else:
|
||||||
|
# Permanent DNS failure (bad domain)
|
||||||
|
return ApiResponse(valid=False, error_type=ErrorType.DNS_PERMANENT, backoff_strategy=BackoffStrategy.NO_RETRY)
|
||||||
|
else:
|
||||||
|
# Non-DNS connection issue,
|
||||||
|
error_type = ErrorType.CONNECTION_ERROR
|
||||||
|
return ApiResponse(valid=False, error_type=error_type, backoff_strategy=BackoffStrategy.RETRY_EXPONENTIAL)
|
||||||
|
|
||||||
|
|
||||||
|
# except httpx.ProxyError as e:
|
||||||
|
# return ApiResponse(
|
||||||
|
# valid=False,
|
||||||
|
# error_type=ErrorType.TOR_NOT_WORKING,
|
||||||
|
# message=f"Proxy error: {str(e)}",
|
||||||
|
# backoff_strategy=BackoffStrategy.NO_RETRY
|
||||||
|
# )
|
||||||
|
|
||||||
|
except (httpx.ProxyError, ProxyError) as e:
|
||||||
|
# Check if it's a DNS-like failure from Tor
|
||||||
|
if "host unreachable" in str(e).lower() or "name resolution" in str(e).lower():
|
||||||
|
return ApiResponse(
|
||||||
|
valid=False,
|
||||||
|
error_type=ErrorType.DNS_PERMANENT,
|
||||||
|
backoff_strategy=BackoffStrategy.NO_RETRY
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Actual Tor infrastructure problem
|
||||||
|
return ApiResponse(
|
||||||
|
valid=False,
|
||||||
|
error_type=ErrorType.TOR_NOT_WORKING,
|
||||||
|
backoff_strategy=BackoffStrategy.NO_RETRY
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
# Catches any other httpx request errors not covered above
|
||||||
|
return ApiResponse(
|
||||||
|
valid=False,
|
||||||
|
error_type=ErrorType.NETWORK_ERROR,
|
||||||
|
message=f"Request error: {str(e)}",
|
||||||
|
backoff_strategy=BackoffStrategy.RETRY_EXPONENTIAL
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Catch anything that slipped through
|
||||||
|
logger.error(f"Unexpected error in _make_async_request: {type(e).__name__}: {e}")
|
||||||
|
return ApiResponse(
|
||||||
|
valid=False,
|
||||||
|
error_type=ErrorType.UNKNOWN,
|
||||||
|
message=f"Unexpected error: {str(e)}",
|
||||||
|
backoff_strategy=BackoffStrategy.NO_RETRY
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def switch_get_and_post(method: str, url: str, client: httpx.Client, payload: dict) -> ApiResponse:
|
||||||
|
if method == "get":
|
||||||
|
new_method = "post"
|
||||||
|
else:
|
||||||
|
new_method = "get"
|
||||||
|
second_result = await _make_async_request(
|
||||||
|
method=new_method,
|
||||||
|
url=url,
|
||||||
|
client=client,
|
||||||
|
payload=payload
|
||||||
|
)
|
||||||
|
if second_result.valid:
|
||||||
|
logger.info("Our strategy worked! This is a mistake of GET/POST")
|
||||||
|
return second_result
|
||||||
|
else:
|
||||||
|
# nevermind,
|
||||||
|
logger.info("Our strategy of switching GET/POST did NOT work.")
|
||||||
|
return initial_result
|
||||||
|
|
@ -16,7 +16,7 @@ import json
|
||||||
def classify_response(response) -> ApiResponse:
|
def classify_response(response) -> ApiResponse:
|
||||||
|
|
||||||
if not isinstance(response, httpx.Response):
|
if not isinstance(response, httpx.Response):
|
||||||
error_msg = "Library Error: HTTPx library returned an invalid object response."
|
error_msg = "invalid object response, its not even HTTPx"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return ApiResponse(
|
return ApiResponse(
|
||||||
valid=False,
|
valid=False,
|
||||||
|
|
|
||||||
363
core/services/networking/httpx/connect.py
Normal file
363
core/services/networking/httpx/connect.py
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
from core.services.networking.httpx import httpx_client
|
||||||
|
from core.services.networking.httpx.make_request import make_request
|
||||||
|
|
||||||
|
from core.services.networking.tor_tools.tor_orchestrator import establish_tor_connection
|
||||||
|
from core.services.networking.tor_tools.tor_dns import setup_SINGLE_use_resolver
|
||||||
|
|
||||||
|
from core.services.networking.api_requests.ApiResponseModel import ApiResponse, ErrorType
|
||||||
|
from core.services.networking.httpx.async_batch_requests import async_parallel
|
||||||
|
from core.services.networking.tor_tools.pre_bootstrap import get_bootstrap_port
|
||||||
|
from core.services.networking.api_requests.subtools.get_connection_type import get_connection_type
|
||||||
|
from core.services.networking.api_requests.subtools.extract_domain import extract_domain, swap_domain_for_ip
|
||||||
|
|
||||||
|
from core.controllers.ConfigurationController import ConfigurationController
|
||||||
|
from core.models.Configuration import Configuration, ConnectionChoice
|
||||||
|
|
||||||
|
from core.services.networking.httpx.endpoints import get_endpoints
|
||||||
|
from core.services.networking.httpx.parallel_threading import parallel_thread
|
||||||
|
from core.services.networking.tor_tools import ports
|
||||||
|
from core.Constants import Constants
|
||||||
|
from essentials.observers.ConnectionObserver import ConnectionObserver
|
||||||
|
from core.errors.logger import logger
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
_port_used = None
|
||||||
|
|
||||||
|
|
||||||
|
def single_endpoint(method: str, url: str, observer: ConnectionObserver, payload: dict = None) -> ApiResponse:
|
||||||
|
"""
|
||||||
|
Rank:
|
||||||
|
Orchestrator
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
Try a request to a single endpoint using either an existing client, or creating a new one, then error handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
global _port_used
|
||||||
|
|
||||||
|
connection_type = ConfigurationController.get_connection_enum()
|
||||||
|
|
||||||
|
client = httpx_client.get_http_session()
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
# NO CLIENT
|
||||||
|
########################################################
|
||||||
|
if client is None:
|
||||||
|
made_client = _make_client(connection_type, observer)
|
||||||
|
if not made_client and connection_type == ConnectionChoice.TOR:
|
||||||
|
return bootstrap_and_try_again(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
observer=observer,
|
||||||
|
payload=payload
|
||||||
|
)
|
||||||
|
elif not made_client and connection_type == ConnectionChoice.SYSTEM:
|
||||||
|
return ApiResponse(valid=False, error_type=ErrorType.DEVELOPER_ERROR, message="Can't make a local non-Tor HTTPx client. This error should not go off.")
|
||||||
|
|
||||||
|
# now get it, regardless of Tor or Not:
|
||||||
|
client = httpx_client.get_http_session()
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
# FROM THIS POINT ON, WE HAVE A CLIENT
|
||||||
|
########################################################
|
||||||
|
# #### SPOOF
|
||||||
|
# _port_used = 9050
|
||||||
|
# initial_result = ApiResponse(valid=False, error_type=ErrorType.DNS_TEMPORARY)
|
||||||
|
|
||||||
|
initial_result = make_request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
client=client,
|
||||||
|
payload=payload
|
||||||
|
)
|
||||||
|
if initial_result.valid:
|
||||||
|
return initial_result
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
# FROM THIS POINT ON, WE HAVE PROBLEMS
|
||||||
|
########################################################
|
||||||
|
if connection_type == ConnectionChoice.SYSTEM:
|
||||||
|
# We have no solutions for clearweb that weren't tried already.
|
||||||
|
# But may add more in the future
|
||||||
|
return initial_result
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
# TOR NOT WORKING. BOOTSTRAP
|
||||||
|
########################################################
|
||||||
|
if initial_result.error_type == ErrorType.TOR_NOT_WORKING:
|
||||||
|
return bootstrap_and_try_again(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
observer=observer,
|
||||||
|
payload=payload
|
||||||
|
)
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
# DNS BLOCK
|
||||||
|
########################################################
|
||||||
|
if initial_result.error_type == ErrorType.DNS_TEMPORARY:
|
||||||
|
return custom_dns_resolver_for_SINGLE_ENDPOINT(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
observer=observer,
|
||||||
|
payload=payload
|
||||||
|
)
|
||||||
|
|
||||||
|
# nothing we can do here
|
||||||
|
else:
|
||||||
|
return initial_result
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client(connection_type: str, observer) -> httpx.Client | ApiResponse:
|
||||||
|
"""
|
||||||
|
Rank:
|
||||||
|
Coordinator
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
Create an HTTPx Client for either kind of transport
|
||||||
|
"""
|
||||||
|
global _port_used
|
||||||
|
|
||||||
|
# Clearweb
|
||||||
|
if connection_type == ConnectionChoice.SYSTEM:
|
||||||
|
return httpx_client.init_session() # this is not the client, its a boolean
|
||||||
|
|
||||||
|
if _port_used is None:
|
||||||
|
_port_used = Constants.DEFAULT_TOR_PORT
|
||||||
|
|
||||||
|
# Tor:
|
||||||
|
|
||||||
|
# check if port is even listening:
|
||||||
|
listening = ports.is_port_in_use(_port_used)
|
||||||
|
if listening:
|
||||||
|
client = httpx_client.init_tor_session(_port_used)
|
||||||
|
if client:
|
||||||
|
return client
|
||||||
|
else:
|
||||||
|
logger.error(f"Could NOT create a Tor HTTPx client on port {_port_used}.. Bootstrapping..")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_and_try_again(method: str, url: str, observer: ConnectionObserver, payload: dict = None):
|
||||||
|
"""
|
||||||
|
Rank:
|
||||||
|
Coordinator
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
Get a port
|
||||||
|
Test it
|
||||||
|
Make a client with it
|
||||||
|
Use it to do the request again
|
||||||
|
|
||||||
|
Called by:
|
||||||
|
single_endpoint
|
||||||
|
"""
|
||||||
|
global _port_used
|
||||||
|
|
||||||
|
if _port_used is None:
|
||||||
|
_port_used = Constants.DEFAULT_TOR_PORT
|
||||||
|
|
||||||
|
bootstrap_results = get_bootstrap_port(port_tried=_port_used, observer=observer)
|
||||||
|
|
||||||
|
# BOOTSTRAP FAILURE
|
||||||
|
if not bootstrap_results.valid and bootstrap_results.port:
|
||||||
|
return bootstrap_results
|
||||||
|
|
||||||
|
# BOOTSTRAP SUCCESS
|
||||||
|
_port_used = bootstrap_results.port
|
||||||
|
|
||||||
|
made_client = httpx_client.init_tor_session(_port_used)
|
||||||
|
if not made_client:
|
||||||
|
return bootstrap_results
|
||||||
|
|
||||||
|
client = httpx_client.get_http_session()
|
||||||
|
return make_request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
client=client,
|
||||||
|
payload=payload
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def bulk_async(wanted_list: list, observer: ConnectionObserver) -> ApiResponse:
|
||||||
|
"""
|
||||||
|
Rank:
|
||||||
|
Orchestrator
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
Do a bulk async data fetch, & Error handle it
|
||||||
|
"""
|
||||||
|
|
||||||
|
global _port_used
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
# PREP SETTINGS & GOAL
|
||||||
|
########################################################
|
||||||
|
connection_type = ConfigurationController.get_connection_enum()
|
||||||
|
desired_endpoints = get_endpoints(wanted_list=wanted_list, dns_resolver=False)
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
# CLEARWEB "regular" INTERNET
|
||||||
|
########################################################
|
||||||
|
if connection_type == ConnectionChoice.SYSTEM:
|
||||||
|
return async_parallel(desired_endpoints=desired_endpoints, port=None)
|
||||||
|
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
# TOR. CHECK THE TOR PORTS
|
||||||
|
########################################################
|
||||||
|
if _port_used is None:
|
||||||
|
_port_used = Constants.DEFAULT_TOR_PORT
|
||||||
|
|
||||||
|
# check if that (default) port is even listening:
|
||||||
|
listening = ports.is_port_in_use(_port_used)
|
||||||
|
|
||||||
|
if listening:
|
||||||
|
results = async_parallel(desired_endpoints=desired_endpoints, port=_port_used)
|
||||||
|
else:
|
||||||
|
new_port = just_only_bootstrap_port(port_used=_port_used, observer=observer)
|
||||||
|
if new_port:
|
||||||
|
_port_used = new_port # Set this as the port for next time
|
||||||
|
results = async_parallel(desired_endpoints=desired_endpoints, port=new_port)
|
||||||
|
else:
|
||||||
|
return ApiResponse(valid=False, error_type=ErrorType.TOR_NOT_WORKING)
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
# EVALUATE RESULTS.
|
||||||
|
########################################################
|
||||||
|
if results is None:
|
||||||
|
return ApiResponse(valid=False, error_type=ErrorType.UNKNOWN)
|
||||||
|
|
||||||
|
return _evaluate_results(results, observer=observer)
|
||||||
|
|
||||||
|
|
||||||
|
def just_only_bootstrap_port(port_tried: int, observer: ConnectionObserver) -> ApiResponse|int:
|
||||||
|
bootstrap_results = get_bootstrap_port(port_tried=port_tried, observer=observer)
|
||||||
|
if bootstrap_results.valid and bootstrap_results.port:
|
||||||
|
return bootstrap_results.port
|
||||||
|
else:
|
||||||
|
logger.error(f"Bootstrap Failed. The reason is likely ALREADY in the error log, and there is NOTHING WE CAN DO ABOUT IT NOW. but here it is again after retries: {bootstrap_results.error_type} and {bootstrap_results.message}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_results(results: ApiResponse, observer: ConnectionObserver) -> ApiResponse:
|
||||||
|
"""
|
||||||
|
Purpose:
|
||||||
|
ALL async bulk results flow through here. Good or bad.
|
||||||
|
Then we categorize individual failures and attempt a group recovery.
|
||||||
|
|
||||||
|
Rank:
|
||||||
|
Mini-Coordinator
|
||||||
|
|
||||||
|
Called by:
|
||||||
|
bulk_async
|
||||||
|
"""
|
||||||
|
########################################################
|
||||||
|
# PREP TRACKERS & DATA
|
||||||
|
########################################################
|
||||||
|
all_data = results.data
|
||||||
|
working_results = {}
|
||||||
|
tor_problem_list = []
|
||||||
|
dns_problem_list = []
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
# Categorize each result
|
||||||
|
########################################################
|
||||||
|
for key, reply in all_data.items():
|
||||||
|
logger.info(f"DEBUG: Processing failure key={key}. with the reply={reply}, reply.error_type={reply.error_type if hasattr(reply, 'error_type') else 'NO ATTR'}")
|
||||||
|
if reply.valid:
|
||||||
|
working_results[key] = reply
|
||||||
|
continue
|
||||||
|
|
||||||
|
if reply.error_type == ErrorType.DNS_TEMPORARY:
|
||||||
|
logger.info(f"Adding {key} to DNS problem list (recoverable).")
|
||||||
|
dns_problem_list.append(key)
|
||||||
|
|
||||||
|
elif reply.error_type == ErrorType.TOR_NOT_WORKING:
|
||||||
|
logger.info(f"Adding {key} to Tor problem list (can bootstrap new port).")
|
||||||
|
tor_problem_list.append(key)
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.info(f"Skipping {key} — error type {reply.error_type} is not recoverable.")
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
# DNS FAILURE. SOLUTION: CUSTOM RESOLVER
|
||||||
|
########################################################
|
||||||
|
if dns_problem_list:
|
||||||
|
logger.info(f"Attempting DNS resolution recovery for: {dns_problem_list}")
|
||||||
|
recovered_results = custom_dns_resolver_for_BULK_THREADING(observer=observer, dns_problem_list=dns_problem_list) # returns a dict
|
||||||
|
|
||||||
|
if recovered_results:
|
||||||
|
return ApiResponse(valid=True, data=recovered_results, error_type=ErrorType.TOR_DNS_BLOCKED)
|
||||||
|
else:
|
||||||
|
# Preserve working results—caller sees what *did* work
|
||||||
|
return ApiResponse(valid=False, data=working_results, error_type=ErrorType.TOR_DNS_BLOCKED)
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
# TOR NOT WORKING. SOLUTION: NEW BOOTSTRAP
|
||||||
|
########################################################
|
||||||
|
if tor_problem_list:
|
||||||
|
logger.info(f"Attempting Tor bootstrap recovery for: {tor_problem_list}")
|
||||||
|
tor_endpoints = get_endpoints(wanted_list=tor_problem_list, dns_resolver=False)
|
||||||
|
new_port = just_only_bootstrap_port(port_tried=_port_used, observer=observer)
|
||||||
|
if new_port:
|
||||||
|
return async_parallel(desired_endpoints=tor_endpoints, port=new_port)
|
||||||
|
else:
|
||||||
|
# Preserve working results—caller sees what *did* work
|
||||||
|
return ApiResponse(valid=False, data=working_results, error_type=ErrorType.TOR_NOT_WORKING)
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
# IF IT MADE IT HERE THEN: Either it worked, or we can't solve it.
|
||||||
|
########################################################
|
||||||
|
# Send working results — Caller can't solve the failure if we can't.
|
||||||
|
return ApiResponse(valid=True, data=working_results)
|
||||||
|
|
||||||
|
|
||||||
|
def custom_dns_resolver_for_BULK_THREADING(dns_problem_list: list, observer: ConnectionObserver) -> dict:
|
||||||
|
# now the real one with the IP address hardcoded:
|
||||||
|
client = httpx_client.setup_tor_session_WITH_CUSTOM_DNS(port=_port_used)
|
||||||
|
|
||||||
|
# now we have the IP address stored in the httpx_client global to call upon inside this function:
|
||||||
|
dns_endpoints = get_endpoints(wanted_list=dns_problem_list, dns_resolver=True)
|
||||||
|
|
||||||
|
return parallel_thread(client, desired_endpoints=dns_endpoints) # threading returns a dict!
|
||||||
|
|
||||||
|
|
||||||
|
def custom_dns_resolver_for_SINGLE_ENDPOINT(method: str, url: str, observer: ConnectionObserver, payload: dict = None) -> dict:
|
||||||
|
########################################################
|
||||||
|
# MAKE DNS RESOLVER
|
||||||
|
########################################################
|
||||||
|
domain = extract_domain(url)
|
||||||
|
|
||||||
|
# is this our API? If so, we're making a client to persist across sessions,
|
||||||
|
if domain == extract_domain(Constants.SP_API_BASE_URL):
|
||||||
|
client = httpx_client.setup_tor_session_WITH_CUSTOM_DNS(domain=domain, port=_port_used)
|
||||||
|
ip_address = httpx_client.get_cached_ip_address()
|
||||||
|
logger.info(f"Got IP of {ip_address} for {domain}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# new domain, make only a one time session outside of the httpx_client module:
|
||||||
|
client, ip_address = setup_SINGLE_use_resolver(domain=domain, port=_port_used)
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
# USE DNS RESOLVER
|
||||||
|
########################################################
|
||||||
|
|
||||||
|
# swap the single url:
|
||||||
|
url_with_ip = swap_domain_for_ip(domain, url, ip_address)
|
||||||
|
|
||||||
|
return make_request(
|
||||||
|
method=method,
|
||||||
|
url=url_with_ip,
|
||||||
|
client=client,
|
||||||
|
payload=payload
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# def merge_dicts(dict1, dict2):
|
||||||
|
# """Merge two dictionaries. dict1 values take precedence on key collision."""
|
||||||
|
# return {**dict2, **dict1}
|
||||||
|
|
||||||
48
core/services/networking/httpx/dns_resolver.py
Normal file
48
core/services/networking/httpx/dns_resolver.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
from core.errors.logger import logger
|
||||||
|
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
import ssl
|
||||||
|
import certifi
|
||||||
|
from unittest.mock import patch
|
||||||
|
import httpcore
|
||||||
|
|
||||||
|
def create_httpx_client_with_custom_dns(hostname_to_ip_map: dict, custom_proxy: str):
|
||||||
|
"""
|
||||||
|
Purpose:
|
||||||
|
Creates an httpx.Client that connects to IPs while verifying certificates
|
||||||
|
against the original hostnames (via SNI).
|
||||||
|
"""
|
||||||
|
|
||||||
|
ssl_context = ssl.create_default_context(cafile=certifi.where())
|
||||||
|
ssl_context.check_hostname = True
|
||||||
|
ssl_context.verify_mode = ssl.CERT_REQUIRED
|
||||||
|
|
||||||
|
# Store the original wrap_socket method
|
||||||
|
original_wrap_socket = ssl.SSLContext.wrap_socket
|
||||||
|
|
||||||
|
def patched_wrap_socket(self, sock, *args, **kwargs):
|
||||||
|
# Inject server_hostname for IPs in our map
|
||||||
|
for hostname, ip in hostname_to_ip_map.items():
|
||||||
|
try:
|
||||||
|
peer_addr = sock.getpeername()
|
||||||
|
if peer_addr[0] == ip:
|
||||||
|
kwargs['server_hostname'] = hostname
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return original_wrap_socket(self, sock, *args, **kwargs)
|
||||||
|
|
||||||
|
# Apply the patch PERMANENTLY
|
||||||
|
ssl.SSLContext.wrap_socket = patched_wrap_socket
|
||||||
|
|
||||||
|
# Build mounts
|
||||||
|
transport = httpx.HTTPTransport(verify=ssl_context, http2=True)
|
||||||
|
mounts = {}
|
||||||
|
for hostname, ip in hostname_to_ip_map.items():
|
||||||
|
mounts[f"https://{hostname}"] = transport
|
||||||
|
mounts[f"https://{ip}"] = transport
|
||||||
|
|
||||||
|
# Create and return client
|
||||||
|
client = httpx.Client(mounts=mounts, http2=True, proxy=custom_proxy)
|
||||||
|
return client
|
||||||
27
core/services/networking/httpx/endpoints.py
Normal file
27
core/services/networking/httpx/endpoints.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
from core.services.networking.httpx.httpx_client import get_cached_ip_address
|
||||||
|
from core.services.networking.api_requests.subtools.extract_domain import extract_domain
|
||||||
|
from core.Constants import Constants
|
||||||
|
|
||||||
|
def switch_endpoint_domain(domain: str) -> dict:
|
||||||
|
return {
|
||||||
|
"locations": f"https://{domain}/api/v1/locations",
|
||||||
|
"operators": f"https://{domain}/api/v1/operators",
|
||||||
|
"client": f"https://{domain}/api/v1/platforms/linux-x86_64/appimage/client-versions",
|
||||||
|
"sub_plans": f"https://{domain}/api/v1/subscription-plans",
|
||||||
|
"applications": f"https://{domain}/api/v1/platforms/linux-x86_64/applications",
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_endpoints(wanted_list: list, dns_resolver: bool = False) -> dict:
|
||||||
|
|
||||||
|
if dns_resolver:
|
||||||
|
ip_address = get_cached_ip_address() # from when the HTTPx Client was setup
|
||||||
|
all_endpoints = switch_endpoint_domain(domain=ip_address)
|
||||||
|
else:
|
||||||
|
all_endpoints = switch_endpoint_domain(domain=extract_domain(Constants.SP_API_BASE_URL))
|
||||||
|
|
||||||
|
# Which ones do you want?
|
||||||
|
if wanted_list == ["all"]:
|
||||||
|
return all_endpoints
|
||||||
|
else:
|
||||||
|
return {key: all_endpoints[key] for key in wanted_list if key in all_endpoints}
|
||||||
|
|
||||||
|
|
@ -1,10 +1,19 @@
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Module Purpose:
|
Module Purpose:
|
||||||
Manage and reuse an HTTPx Client across modules
|
Manage and reuse an HTTPx Client across modules.
|
||||||
|
|
||||||
|
|
||||||
|
init_tor_session - Setup a setup a tor proxy client
|
||||||
|
|
||||||
|
setup_tor_session_WITH_CUSTOM_DNS - Manually lookup IP addresses through a Tor Proxy, then use them with SSL, and verify SSL with a custom local resolver.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from core.services.networking.tor_tools import tor_dns
|
||||||
|
from core.services.networking.httpx.dns_resolver import create_httpx_client_with_custom_dns
|
||||||
|
from core.services.networking.api_requests.subtools.extract_domain import extract_domain
|
||||||
from core.errors.logger import logger
|
from core.errors.logger import logger
|
||||||
|
from core.Constants import Constants
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from httpx_socks import AsyncProxyTransport
|
from httpx_socks import AsyncProxyTransport
|
||||||
|
|
@ -12,8 +21,19 @@ import httpx_socks
|
||||||
from httpx_socks import ProxyType
|
from httpx_socks import ProxyType
|
||||||
|
|
||||||
_http_client = None
|
_http_client = None
|
||||||
|
_ip_address = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_session() -> bool:
|
||||||
|
global _http_client
|
||||||
|
try:
|
||||||
|
_http_client = httpx.Client(http2=True, timeout=10)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unknown error with creating an non-Tor httpx client: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# test tor & get a basic client
|
||||||
def init_tor_session(port: int = 9050) -> bool:
|
def init_tor_session(port: int = 9050) -> bool:
|
||||||
"""
|
"""
|
||||||
Purpose:
|
Purpose:
|
||||||
|
|
@ -58,6 +78,37 @@ def init_tor_session(port: int = 9050) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def setup_tor_session_WITH_CUSTOM_DNS(
|
||||||
|
port: int = 9050,
|
||||||
|
domain: str = extract_domain(Constants.SP_API_BASE_URL)
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Purpose:
|
||||||
|
Sets up a Custom HTTPx Proxy Client,
|
||||||
|
using IP addresses instead of DNS, and then verifying SSL locally.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Step 1 is done for us prior to calling this function, (called init_tor_session)
|
||||||
|
global _http_client
|
||||||
|
global _ip_address
|
||||||
|
|
||||||
|
# setup proxy for the DNS resolver:
|
||||||
|
tor_proxy = f"socks5h://127.0.0.1:{port}"
|
||||||
|
|
||||||
|
# Step 2, get the raw IP address:
|
||||||
|
_ip_address = tor_dns.quad9_lookup(domain=domain, custom_proxy=tor_proxy)
|
||||||
|
logger.info(f"Got IP address of {_ip_address}")
|
||||||
|
|
||||||
|
# Step 3, feed the IP address/proxy, and domain into the DNS resolver:
|
||||||
|
_http_client = create_httpx_client_with_custom_dns(
|
||||||
|
hostname_to_ip_map={domain: _ip_address},
|
||||||
|
custom_proxy=tor_proxy
|
||||||
|
)
|
||||||
|
logger.info("Setup Custom DNS HTTPx Client!")
|
||||||
|
|
||||||
|
return _http_client
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def init_untested_tor(port: int = 9050) -> bool:
|
def init_untested_tor(port: int = 9050) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
@ -78,9 +129,16 @@ def init_untested_tor(port: int = 9050) -> bool:
|
||||||
def get_http_session() -> httpx.Client:
|
def get_http_session() -> httpx.Client:
|
||||||
"""Return the global _http_client or raise RuntimeError."""
|
"""Return the global _http_client or raise RuntimeError."""
|
||||||
if _http_client is None:
|
if _http_client is None:
|
||||||
raise RuntimeError("HTTP session not initialized. Call init_tor_session(port) first.")
|
return None
|
||||||
|
# raise RuntimeError("HTTP session not initialized. Call init_tor_session(port) first.")
|
||||||
return _http_client
|
return _http_client
|
||||||
|
|
||||||
|
def get_cached_ip_address() -> str:
|
||||||
|
"""Return the global _ip_address or raise RuntimeError."""
|
||||||
|
if _ip_address is None:
|
||||||
|
raise RuntimeError("Custom DNS Resolver was never initialized. Call setup_tor_session_WITH_CUSTOM_DNS first.")
|
||||||
|
return _ip_address
|
||||||
|
|
||||||
|
|
||||||
def close_http_session():
|
def close_http_session():
|
||||||
"""Close and reset the global _http_client."""
|
"""Close and reset the global _http_client."""
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import asyncio
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import socket
|
import socket
|
||||||
|
from python_socks._errors import ProxyError
|
||||||
|
|
||||||
def make_request(
|
def make_request(
|
||||||
method: str,
|
method: str,
|
||||||
|
|
@ -91,35 +92,53 @@ def _make_request(
|
||||||
|
|
||||||
|
|
||||||
except httpx.ConnectError as e:
|
except httpx.ConnectError as e:
|
||||||
|
logger.error(f"Error: {e}")
|
||||||
if isinstance(e.__cause__, socket.gaierror):
|
if isinstance(e.__cause__, socket.gaierror):
|
||||||
gaierror = e.__cause__
|
gaierror = e.__cause__
|
||||||
if gaierror.errno in (-3, -11): # EAI_AGAIN
|
if gaierror.errno in (-3, -11): # EAI_AGAIN
|
||||||
# Transient DNS failure
|
if "Name or service not known" in e:
|
||||||
return ApiResponse(valid=False, error_type=ErrorType.DNS_TEMPORARY, backoff_strategy=BackoffStrategy.RETRY_EXPONENTIAL)
|
|
||||||
else:
|
|
||||||
# Permanent DNS failure (bad domain)
|
# Permanent DNS failure (bad domain)
|
||||||
return ApiResponse(valid=False, error_type=ErrorType.DNS_PERMANENT, backoff_strategy=BackoffStrategy.NO_RETRY)
|
return ApiResponse(valid=False, error_type=ErrorType.DNS_PERMANENT, backoff_strategy=BackoffStrategy.NO_RETRY)
|
||||||
|
else:
|
||||||
|
# Transient DNS failure
|
||||||
|
return ApiResponse(valid=False, error_type=ErrorType.DNS_TEMPORARY, backoff_strategy=BackoffStrategy.NO_RETRY) # this is going to go to DNS resolver.
|
||||||
|
# else:
|
||||||
|
# # Transient DNS failure
|
||||||
|
# return ApiResponse(valid=False, error_type=ErrorType.DNS_TEMPORARY, backoff_strategy=BackoffStrategy.NO_RETRY) # this is going to go to DNS resolver.
|
||||||
else:
|
else:
|
||||||
# Non-DNS connection issue,
|
# Non-DNS connection issue,
|
||||||
error_type = ErrorType.CONNECT_ERROR
|
error_type = ErrorType.CONNECTION_ERROR
|
||||||
return ApiResponse(valid=False, error_type=error_type, backoff_strategy=BackoffStrategy.RETRY_EXPONENTIAL)
|
return ApiResponse(valid=False, error_type=error_type, backoff_strategy=BackoffStrategy.RETRY_EXPONENTIAL)
|
||||||
|
|
||||||
|
|
||||||
except httpx.ProxyError as e:
|
except httpx.ProxyError as e:
|
||||||
|
logger.error(f"Error: {e}")
|
||||||
|
if isinstance(e.__cause__, socket.gaierror):
|
||||||
|
gaierror = e.__cause__
|
||||||
|
if gaierror.errno in (-3, -11): # EAI_AGAIN
|
||||||
|
if "Host unreachable" in e:
|
||||||
|
# Permanent DNS failure (bad domain)
|
||||||
|
return ApiResponse(valid=False, error_type=ErrorType.DNS_PERMANENT, backoff_strategy=BackoffStrategy.NO_RETRY)
|
||||||
|
else:
|
||||||
|
# Transient DNS failure
|
||||||
|
return ApiResponse(valid=False, error_type=ErrorType.DNS_TEMPORARY, backoff_strategy=BackoffStrategy.NO_RETRY) # this is going to go to DNS resolver.
|
||||||
|
else:
|
||||||
return ApiResponse(
|
return ApiResponse(
|
||||||
valid=False,
|
valid=False,
|
||||||
error_type=ErrorType.TOR_NOT_WORKING,
|
error_type=ErrorType.TOR_NOT_WORKING,
|
||||||
message=f"Proxy error: {str(e)}",
|
message=f"Proxy error: {str(e)}",
|
||||||
backoff_strategy=BackoffStrategy.NO_RETRY
|
backoff_strategy=BackoffStrategy.NO_RETRY
|
||||||
)
|
)
|
||||||
|
print("this should not print")
|
||||||
|
|
||||||
except httpx.SSLError as e:
|
|
||||||
return ApiResponse(
|
|
||||||
valid=False,
|
# except httpx.SSLError as e:
|
||||||
error_type=ErrorType.NETWORK_ERROR,
|
# return ApiResponse(
|
||||||
message=f"SSL/certificate error: {str(e)}",
|
# valid=False,
|
||||||
backoff_strategy=BackoffStrategy.NO_RETRY # Permanent cert issue
|
# error_type=ErrorType.NETWORK_ERROR,
|
||||||
)
|
# message=f"SSL/certificate error: {str(e)}",
|
||||||
|
# backoff_strategy=BackoffStrategy.NO_RETRY # Permanent cert issue
|
||||||
|
# )
|
||||||
|
|
||||||
except httpx.RequestError as e:
|
except httpx.RequestError as e:
|
||||||
# Catches any other httpx request errors not covered above
|
# Catches any other httpx request errors not covered above
|
||||||
|
|
@ -130,6 +149,15 @@ def _make_request(
|
||||||
backoff_strategy=BackoffStrategy.RETRY_EXPONENTIAL
|
backoff_strategy=BackoffStrategy.RETRY_EXPONENTIAL
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return ApiResponse(
|
||||||
|
valid=False,
|
||||||
|
error_type=ErrorType.UNKNOWN,
|
||||||
|
message=f"Request error: {str(e)}",
|
||||||
|
backoff_strategy=BackoffStrategy.NO_RETRY
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def switch_get_and_post(method: str, url: str, client: httpx.Client, payload: dict) -> ApiResponse:
|
def switch_get_and_post(method: str, url: str, client: httpx.Client, payload: dict) -> ApiResponse:
|
||||||
if method == "get":
|
if method == "get":
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
from core.services.networking.httpx.make_request import make_request
|
from core.services.networking.httpx.make_request import make_request
|
||||||
|
# from core.services.networking.httpx.endpoints import get_endpoints
|
||||||
from core.services.networking.httpx.httpx_client import init_tor_session, get_http_session, init_untested_tor
|
from core.services.networking.httpx.httpx_client import init_tor_session, get_http_session, init_untested_tor
|
||||||
from core.services.networking.api_requests.ApiResponseModel import ApiResponse, ErrorType, BackoffStrategy
|
from core.services.networking.api_requests.ApiResponseModel import ApiResponse, ErrorType, BackoffStrategy
|
||||||
|
|
||||||
from core.errors.logger import logger
|
from core.errors.logger import logger
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
@ -10,26 +12,10 @@ import httpx
|
||||||
import httpx_socks # pip install httpx-socks
|
import httpx_socks # pip install httpx-socks
|
||||||
from httpx_socks import ProxyType
|
from httpx_socks import ProxyType
|
||||||
|
|
||||||
def if_a_new_client_is_needed():
|
|
||||||
transport = httpx_socks.SyncProxyTransport(
|
|
||||||
proxy_type=ProxyType.SOCKS5,
|
|
||||||
proxy_host="127.0.0.1",
|
|
||||||
proxy_port=9050,
|
|
||||||
)
|
|
||||||
client = httpx.Client(transport=transport, http2=True, timeout=10)
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
def parallel_thread(client: httpx.Client, desired_endpoints: dict) -> dict:
|
||||||
|
|
||||||
ENDPOINTS = {
|
# desired_endpoints = get_endpoints(wanted_list)
|
||||||
"locations": "https://api.hydraveil.net/api/v1/locations",
|
|
||||||
"operators": "https://api.hydraveil.net/api/v1/operators",
|
|
||||||
"client": "https://api.hydraveil.net/api/v1/platforms/linux-x86_64/appimage/client-versions",
|
|
||||||
"sub_plans": "https://api.hydraveil.net/api/v1/subscription-plans",
|
|
||||||
"applications": "https://api.hydraveil.net/api/v1/platforms/linux-x86_64/applications",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def parallel_thread(client: httpx.Client, endpoints: dict = ENDPOINTS) -> dict:
|
|
||||||
start_total = time.time()
|
start_total = time.time()
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=5) as executor:
|
with ThreadPoolExecutor(max_workers=5) as executor:
|
||||||
|
|
@ -40,7 +26,7 @@ def parallel_thread(client: httpx.Client, endpoints: dict = ENDPOINTS) -> dict:
|
||||||
url=url,
|
url=url,
|
||||||
client=client
|
client=client
|
||||||
)
|
)
|
||||||
for key, url in endpoints.items()
|
for key, url in desired_endpoints.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
# Wait for all jobs to finish and collect results (BLOCKING)
|
# Wait for all jobs to finish and collect results (BLOCKING)
|
||||||
|
|
@ -50,3 +36,14 @@ def parallel_thread(client: httpx.Client, endpoints: dict = ENDPOINTS) -> dict:
|
||||||
logger.info(f"\nTotal time for all API calls: {total_parallel:.2f}s")
|
logger.info(f"\nTotal time for all API calls: {total_parallel:.2f}s")
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# def if_a_new_client_is_needed():
|
||||||
|
# transport = httpx_socks.SyncProxyTransport(
|
||||||
|
# proxy_type=ProxyType.SOCKS5,
|
||||||
|
# proxy_host="127.0.0.1",
|
||||||
|
# proxy_port=9050,
|
||||||
|
# )
|
||||||
|
# client = httpx.Client(transport=transport, http2=True, timeout=10)
|
||||||
|
# return client
|
||||||
|
|
|
||||||
204
core/services/networking/tor_tools/pre_bootstrap.py
Normal file
204
core/services/networking/tor_tools/pre_bootstrap.py
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
|
||||||
|
from core.services.networking.httpx.httpx_client import init_tor_session, get_http_session, init_untested_tor
|
||||||
|
from core.services.networking.tor_tools.evaluate_tor import evaluate_pre_existing_tor, is_installed
|
||||||
|
from core.services.networking.tor_tools.install_tor import install_tor
|
||||||
|
from core.services.networking.tor_tools import tor_files
|
||||||
|
from core.services.networking.tor_tools import ports
|
||||||
|
from core.services.networking.tor_tools.bootstrap import bootstrap
|
||||||
|
from core.services.networking.api_requests.ApiResponseModel import ApiResponse, ErrorType
|
||||||
|
from core.models.Result import Result, ResultError
|
||||||
|
from core.utils.run_commands import run_generic_command
|
||||||
|
|
||||||
|
|
||||||
|
from core.Constants import Constants
|
||||||
|
from essentials.observers.ConnectionObserver import ConnectionObserver
|
||||||
|
from core.errors.logger import logger
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
from httpx_socks import AsyncProxyTransport
|
||||||
|
# pip install httpx-socks
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# we eliminate by saying what did NOT work
|
||||||
|
def check_if_port_works(port: int) -> bool:
|
||||||
|
listening = ports.is_port_in_use(port)
|
||||||
|
|
||||||
|
if not listening:
|
||||||
|
return False
|
||||||
|
|
||||||
|
worked = init_tor_session(port=port)
|
||||||
|
if worked:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_bootstrap_port(port_tried: int, observer: ConnectionObserver):
|
||||||
|
# diagnosis = diagnose_tor_port(port_tried, observer)
|
||||||
|
|
||||||
|
# if diagnosis.valid:
|
||||||
|
# port = diagnosis.port
|
||||||
|
# else:
|
||||||
|
# port = port_tried
|
||||||
|
# while port == port_tried:
|
||||||
|
|
||||||
|
# Remove the lock
|
||||||
|
removed_lock = tor_files.remove_lock_file()
|
||||||
|
|
||||||
|
# random port:
|
||||||
|
port = ports.get_random_available_port()
|
||||||
|
|
||||||
|
return bootstrap(
|
||||||
|
port=port,
|
||||||
|
observer=observer,
|
||||||
|
use_new_folder=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# if bootstrap_results.valid:
|
||||||
|
# WORKING_PORT = bootstrap_results.port
|
||||||
|
# logger.info(f"worked!! got a tor session on {WORKING_PORT}")
|
||||||
|
# return bootstrap_results
|
||||||
|
|
||||||
|
# # working_on_default = init_tor_session(port=bootstrap_results.port)
|
||||||
|
# if working_on_default:
|
||||||
|
# return ApiResponse(valid=True, port=bootstrap_results.port)
|
||||||
|
# else:
|
||||||
|
# return ApiResponse(valid=False, error_type=ErrorType.UNKNOWN)
|
||||||
|
|
||||||
|
|
||||||
|
# we eliminate by saying what WORKED
|
||||||
|
def diagnose_tor_port(port_tried: int, observer: ConnectionObserver) -> ApiResponse:
|
||||||
|
# Step 1) Is Tor Installed?
|
||||||
|
if not is_installed('tor'):
|
||||||
|
installed = install_tor()
|
||||||
|
if installed:
|
||||||
|
return ApiResponse(valid=True, error_type=ErrorType.TOR_NOT_INSTALLED, port=Constants.DEFAULT_TOR_PORT)
|
||||||
|
else:
|
||||||
|
return ApiResponse(valid=False, error_type=ErrorType.REFUSAL_TO_INSTALL_TOR)
|
||||||
|
|
||||||
|
# Get info from Tor's torrc config:
|
||||||
|
config = tor_files.diagnose_config()
|
||||||
|
|
||||||
|
# config testing,
|
||||||
|
if config.valid and config.port:
|
||||||
|
config_works = check_if_port_works(port=config.port)
|
||||||
|
if config_works:
|
||||||
|
return ApiResponse(valid=True, port=config.port)
|
||||||
|
|
||||||
|
# default testing
|
||||||
|
if port_tried != Constants.DEFAULT_TOR_PORT:
|
||||||
|
default_works = check_if_port_works(port=Constants.DEFAULT_TOR_PORT)
|
||||||
|
if default_works:
|
||||||
|
return ApiResponse(valid=True, error_type=ErrorType.TOR_ON_DIFFERENT_PORT, port=Constants.DEFAULT_TOR_PORT)
|
||||||
|
|
||||||
|
# Is the port you were using even listening?
|
||||||
|
that_port_is_listening = ports.is_port_in_use(port_tried)
|
||||||
|
if not that_port_is_listening:
|
||||||
|
logger.info(f"The port {port_tried} is NOT listening")
|
||||||
|
return ApiResponse(valid=False, error_type=ErrorType.PORT_NOT_LISTENING)
|
||||||
|
|
||||||
|
# screw it, let's get a random port,
|
||||||
|
return ApiResponse(valid=False, error_type=ErrorType.UNKNOWN)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# # # Step 2) Defaults?
|
||||||
|
# # if port_tried == Constants.DEFAULT_TOR_PORT:
|
||||||
|
# # return ApiResponse(valid=False, error_type=ErrorType.DEFAULT_TOR_PORT_DEAD)
|
||||||
|
|
||||||
|
# # # Step 3) is the default listening?
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# logger.info(f"The port {port_tried} is at least listening")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# if
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# valid=True, error_type=ErrorType.TOR_ON_DIFFERENT_PORT
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# return ApiResponse(valid=False, error_type=ErrorType.TOR_INSTALLED_BUT_DEAD, port=config.port)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# if result.valid:
|
||||||
|
# return result
|
||||||
|
|
||||||
|
# logger.info(f"Pre-existing Tor did not work, here's what category we have so far: {result.error_type}")
|
||||||
|
|
||||||
|
# # Step 2) Install Tor if needed.
|
||||||
|
# if result.error_type == ErrorType.TOR_NOT_INSTALLED:
|
||||||
|
# installed = install_tor()
|
||||||
|
# if not installed:
|
||||||
|
# return ApiResponse(valid=False, error_type=ErrorType.REFUSAL_TO_INSTALL_TOR)
|
||||||
|
|
||||||
|
|
||||||
|
# # Step 4) Get a new port:
|
||||||
|
# port = ports.get_random_available_port()
|
||||||
|
|
||||||
|
# # BOOTSTRAP TIME!
|
||||||
|
# logger.info(f"""
|
||||||
|
# Ladies and Gentlemen, this is your captain speaking,
|
||||||
|
|
||||||
|
# We are strapped in for bootstrapping, please fasten your seats & tables to the upright position.
|
||||||
|
|
||||||
|
# Pre-flight Checks Perfomed:
|
||||||
|
|
||||||
|
# 1) Tor is installed!
|
||||||
|
# 2) Default port doesn't work of {Constants.DEFAULT_TOR_PORT}
|
||||||
|
# 3) Config port checked of {result.port}
|
||||||
|
# 4) Did we remove the lock file? {removal_result}
|
||||||
|
# 5) We got a new port of {port}
|
||||||
|
|
||||||
|
# Have a good flight...
|
||||||
|
# """)
|
||||||
|
# bootstrap_results = bootstrap(
|
||||||
|
# port=port,
|
||||||
|
# observer=observer,
|
||||||
|
# use_new_folder=False
|
||||||
|
# )
|
||||||
|
|
||||||
|
# if bootstrap_results.valid:
|
||||||
|
# working_on_default = init_tor_session(port=bootstrap_results.port)
|
||||||
|
# if working_on_default:
|
||||||
|
# print(f"worked!! got a tor session on {port}")
|
||||||
|
# return ApiResponse(valid=True, port=bootstrap_results.port)
|
||||||
|
# else:
|
||||||
|
# return ApiResponse(valid=False, error_type=ErrorType.UNKNOWN)
|
||||||
|
|
||||||
|
|
||||||
|
# # If I need this later:
|
||||||
|
# # async def create_async_tor_client(port: int) -> httpx.AsyncClient:
|
||||||
|
# # """Create an AsyncClient ready for API requests."""
|
||||||
|
# # transport = AsyncProxyTransport.from_url(f"socks5://127.0.0.1:{port}")
|
||||||
|
# # return httpx.AsyncClient(transport=transport, http2=True, timeout=30)
|
||||||
|
|
||||||
|
# # potential: pip install httpx-socks[asyncio] if the async at bottom is used.
|
||||||
|
|
||||||
130
core/services/networking/tor_tools/tor_dns.py
Normal file
130
core/services/networking/tor_tools/tor_dns.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
from core.errors.logger import logger
|
||||||
|
from core.Constants import Constants
|
||||||
|
from core.services.networking.api_requests.ApiResponseModel import ApiResponse, ErrorType
|
||||||
|
from core.services.networking.api_requests.subtools.custom_httpx_dns_resolver import create_httpx_client_with_custom_dns
|
||||||
|
from core.services.networking.api_requests.subtools.extract_domain import extract_domain, swap_domain_for_ip
|
||||||
|
|
||||||
|
from essentials.observers.ConnectionObserver import ConnectionObserver
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import dns.message
|
||||||
|
import dns.name
|
||||||
|
import dns.rdatatype
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
# This version is passed a "generic" proxy with external Tor:
|
||||||
|
def quad9_lookup(
|
||||||
|
domain: str,
|
||||||
|
custom_proxy: str
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
# ================= Use Proxy for Quad9 ===============
|
||||||
|
qname = dns.name.from_text(domain)
|
||||||
|
query = dns.message.make_query(qname, dns.rdatatype.A)
|
||||||
|
wire_format = query.to_wire()
|
||||||
|
encoded = base64.urlsafe_b64encode(wire_format).decode().rstrip('=')
|
||||||
|
|
||||||
|
# Use httpx with HTTP/2
|
||||||
|
with httpx.Client(http2=True, proxy=custom_proxy, timeout=20) as client:
|
||||||
|
response = client.get(
|
||||||
|
"https://dns.quad9.net/dns-query",
|
||||||
|
params={"dns": encoded},
|
||||||
|
headers={"Accept": "application/dns-message"}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"[QUAD9-DNS] http_version: {response.http_version}")
|
||||||
|
logger.debug(f"[QUAD9-DNS] Status: {response.status_code}")
|
||||||
|
logger.debug(f"[QUAD9-DNS] Headers: {response.headers}")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
# ================= Filter Results ===============
|
||||||
|
result = dns.message.from_wire(response.content)
|
||||||
|
logger.debug(f"[QUAD9-DNS] Quad9 Returned {result}")
|
||||||
|
|
||||||
|
ip_address = None
|
||||||
|
for rrset in result.answer:
|
||||||
|
for rr in rrset:
|
||||||
|
if rr.rdtype == dns.rdatatype.A:
|
||||||
|
ip_address = rr.address # Get just the IP address
|
||||||
|
break
|
||||||
|
if ip_address:
|
||||||
|
break
|
||||||
|
|
||||||
|
return ip_address
|
||||||
|
else:
|
||||||
|
logger.error(f"Quad9's Invalid Response Body: {response.text[:500]}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def setup_SINGLE_use_resolver(
|
||||||
|
domain: str,
|
||||||
|
port: int = 9050
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Purpose:
|
||||||
|
This is a duplicate of what's in httpx_client module,,
|
||||||
|
But it's because this designed to not-persist across sessions.
|
||||||
|
|
||||||
|
Sets up a Custom HTTPx Proxy Client,
|
||||||
|
using IP addresses instead of DNS, and then verifying SSL locally.
|
||||||
|
"""
|
||||||
|
# Step 1. setup proxy for the DNS resolver:
|
||||||
|
tor_proxy = f"socks5h://127.0.0.1:{port}"
|
||||||
|
|
||||||
|
# Step 2, get the raw IP address:
|
||||||
|
ip_address = quad9_lookup(domain=domain, custom_proxy=tor_proxy)
|
||||||
|
logger.info(f"Got IP address of {ip_address}")
|
||||||
|
|
||||||
|
# Step 3, feed the IP address/proxy, and domain into the DNS resolver:
|
||||||
|
client = create_httpx_client_with_custom_dns(
|
||||||
|
hostname_to_ip_map={domain: ip_address},
|
||||||
|
custom_proxy=tor_proxy
|
||||||
|
)
|
||||||
|
|
||||||
|
return client, ip_address
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def quad9_lookup_WITH_SAME_CLIENT(
|
||||||
|
domain: str,
|
||||||
|
client: httpx.Client
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
# ================= Use Proxy for Quad9 ===============
|
||||||
|
qname = dns.name.from_text(domain)
|
||||||
|
query = dns.message.make_query(qname, dns.rdatatype.A)
|
||||||
|
wire_format = query.to_wire()
|
||||||
|
encoded = base64.urlsafe_b64encode(wire_format).decode().rstrip('=')
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"https://dns.quad9.net/dns-query",
|
||||||
|
params={"dns": encoded},
|
||||||
|
headers={"Accept": "application/dns-message"}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"[QUAD9-DNS] Status: {response.status_code}")
|
||||||
|
logger.debug(f"[QUAD9-DNS] http_version: {response.http_version}")
|
||||||
|
logger.debug(f"[QUAD9-DNS] Headers: {response.headers}")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
# ================= Filter Results ===============
|
||||||
|
result = dns.message.from_wire(response.content)
|
||||||
|
logger.debug(f"[QUAD9-DNS] Quad9 Returned {result}")
|
||||||
|
|
||||||
|
ip_address = None
|
||||||
|
for rrset in result.answer:
|
||||||
|
for rr in rrset:
|
||||||
|
if rr.rdtype == dns.rdatatype.A:
|
||||||
|
ip_address = rr.address # Get just the IP address
|
||||||
|
break
|
||||||
|
if ip_address:
|
||||||
|
break
|
||||||
|
|
||||||
|
return ip_address
|
||||||
|
else:
|
||||||
|
logger.error(f"Quad9's Invalid Response Body: {response.text[:500]}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ def establish_tor_connection(observer: ConnectionObserver) -> ApiResponse:
|
||||||
King Orchestrator
|
King Orchestrator
|
||||||
|
|
||||||
Purpose:
|
Purpose:
|
||||||
Get a working port for a Tor connection, via any available means.
|
Get a working port for a REGULAR non-async Tor connection, via any available means.
|
||||||
"""
|
"""
|
||||||
# Step 1) Find out if the defaults or any others work.
|
# Step 1) Find out if the defaults or any others work.
|
||||||
result = evaluate_pre_existing_tor(observer)
|
result = evaluate_pre_existing_tor(observer)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "sp-hydra-veil-core"
|
name = "sp-hydra-veil-core"
|
||||||
version = "2.5.4"
|
version = "2.5.5"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Simplified Privacy" },
|
{ name = "Simplified Privacy" },
|
||||||
]
|
]
|
||||||
|
|
@ -46,6 +46,7 @@ dependencies = [
|
||||||
"typing_extensions==4.15.0",
|
"typing_extensions==4.15.0",
|
||||||
"urllib3==2.6.3",
|
"urllib3==2.6.3",
|
||||||
"httpx[http2]==0.28.1",
|
"httpx[http2]==0.28.1",
|
||||||
|
"httpx-socks==0.11.0",
|
||||||
"dnspython==2.8.0",
|
"dnspython==2.8.0",
|
||||||
"socksio==1.0.0",
|
"socksio==1.0.0",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue