From ed055f338d8ee115d925babac82449e64f697e02 Mon Sep 17 00:00:00 2001 From: SimplifiedPrivacy Date: Mon, 3 Aug 2026 16:59:39 -0400 Subject: [PATCH] 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. --- change_log.md | 4 + core/controllers/ConfigurationController.py | 29 +- core/models/Configuration.py | 134 +++---- .../api_requests/ApiResponseModel.py | 5 +- .../networking/httpx/async_batch_requests.py | 119 ++++++ .../networking/httpx/async_request.py | 176 +++++++++ .../networking/httpx/classify_response.py | 2 +- core/services/networking/httpx/connect.py | 363 ++++++++++++++++++ .../services/networking/httpx/dns_resolver.py | 48 +++ core/services/networking/httpx/endpoints.py | 27 ++ .../services/networking/httpx/httpx_client.py | 62 ++- .../services/networking/httpx/make_request.py | 68 +++- .../networking/httpx/parallel_threading.py | 35 +- .../networking/tor_tools/pre_bootstrap.py | 204 ++++++++++ core/services/networking/tor_tools/tor_dns.py | 130 +++++++ .../networking/tor_tools/tor_orchestrator.py | 2 +- pyproject.toml | 3 +- 17 files changed, 1275 insertions(+), 136 deletions(-) create mode 100644 core/services/networking/httpx/async_batch_requests.py create mode 100644 core/services/networking/httpx/async_request.py create mode 100644 core/services/networking/httpx/connect.py create mode 100644 core/services/networking/httpx/dns_resolver.py create mode 100644 core/services/networking/httpx/endpoints.py create mode 100644 core/services/networking/tor_tools/pre_bootstrap.py create mode 100644 core/services/networking/tor_tools/tor_dns.py diff --git a/change_log.md b/change_log.md index c42d847..6c4c067 100644 --- a/change_log.md +++ b/change_log.md @@ -1,6 +1,10 @@ # 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 ### 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. diff --git a/core/controllers/ConfigurationController.py b/core/controllers/ConfigurationController.py index 78499d7..831a7e0 100644 --- a/core/controllers/ConfigurationController.py +++ b/core/controllers/ConfigurationController.py @@ -1,14 +1,23 @@ from core.Errors import UnknownConnectionTypeError -from core.models.Configuration import Configuration +from core.models.Configuration import Configuration, ConnectionChoice from datetime import datetime, timezone from typing import Optional class ConfigurationController: + _config: Optional[Configuration] = None @staticmethod 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 def get_or_new(): @@ -24,16 +33,26 @@ class ConfigurationController: def get_connection(): configuration = ConfigurationController.get() + return configuration.connection.value - if configuration is None or configuration.connection not in ('system', 'tor'): - raise UnknownConnectionTypeError('The preferred connection type could not be determined.') + @staticmethod + def get_connection_enum(): + configuration = ConfigurationController.get() return configuration.connection @staticmethod - def set_connection(connection: Optional[str] = None): + def set_connection(connection_string: Optional[str] = None): 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.save() diff --git a/core/models/Configuration.py b/core/models/Configuration.py index 3dfded5..a2808a9 100644 --- a/core/models/Configuration.py +++ b/core/models/Configuration.py @@ -1,113 +1,75 @@ from core.errors.logger import logger - from core.Constants import Constants 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 marshmallow import fields -from typing import Optional, Self from zoneinfo import ZoneInfo -import dataclasses_json +from typing import Optional, Self import json import os import sys -@dataclass_json -@dataclass -class Configuration: - connection: Optional[str] = field( - default=None, - metadata=config( - undefined=dataclasses_json.Undefined.EXCLUDE, - exclude=lambda value: value is None - ) - ) - auto_sync_enabled: Optional[bool] = field( - default=None, - metadata=config( - undefined=dataclasses_json.Undefined.EXCLUDE, - exclude=lambda value: value is None - ) - ) - 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 - ) - ) +class ConnectionChoice(str, Enum): + TOR = "tor" + SYSTEM = "system" - firewall: Optional[bool] = field( - default=False, - metadata=config( - undefined=dataclasses_json.Undefined.EXCLUDE, - exclude=lambda value: value is None - ) +class Configuration(BaseModel): + connection: Optional[ConnectionChoice] = None + auto_sync_enabled: Optional[bool] = None + endpoint_verification_enabled: Optional[bool] = False + last_synced_at: Optional[datetime] = None + firewall: Optional[bool] = False + dns: Optional[bool] = False + did_sudo_setup: Optional[bool] = False + + + model_config = ConfigDict( + extra='ignore', # Ignore unknown fields in JSON + exclude_none=True # Don't serialize None values ) + + @field_validator('last_synced_at', mode='before') + @classmethod + def parse_datetime(cls, v): + if isinstance(v, str): + v = v.replace('Z', '+00:00') # Z → +00:00 for parsing + return v + + @field_serializer('last_synced_at') + def serialize_datetime(self, value: datetime) -> str: + if value: + value = value.replace(tzinfo=ZoneInfo('UTC')) + return value.isoformat().replace('+00:00', 'Z') # +00:00 → Z for JSON + return None - dns: Optional[bool] = field( - default=False, - metadata=config( - undefined=dataclasses_json.Undefined.EXCLUDE, - exclude=lambda value: value is 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 def get(): - 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: return None - + try: - configuration = json.loads(config_file_contents) + configuration_dict = json.loads(config_file_contents) except ValueError: sys.exit(1) + + return Configuration(**configuration_dict) # Pydantic validates on init - # noinspection PyUnresolvedReferences - configuration = Configuration.from_dict(configuration) - return configuration - - @staticmethod - def _iso_format(datetime_instance: datetime): - datetime_instance = datetime_instance.replace(tzinfo=ZoneInfo('UTC')) - return datetime.isoformat(datetime_instance).replace('+00:00', 'Z') - - @staticmethod - def _from_iso_format(datetime_string: str): - date_string = datetime_string.replace('Z', '+00:00') - return datetime.fromisoformat(date_string) + def save(self: Self): + config_file_contents = f'{self.model_dump_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) def read_config(): diff --git a/core/services/networking/api_requests/ApiResponseModel.py b/core/services/networking/api_requests/ApiResponseModel.py index be89b3f..26b14f0 100644 --- a/core/services/networking/api_requests/ApiResponseModel.py +++ b/core/services/networking/api_requests/ApiResponseModel.py @@ -1,5 +1,5 @@ from enum import Enum -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Optional, Any @@ -39,9 +39,11 @@ class ErrorType(Enum): DNS_PERMANENT = "dns_permanent" QUAD9_DNS_RESOLUTION = "quad9_dns_resolution" NO_INTERNET = "no_internet" + CONNECTION_ERROR = "connection_error" INVALID_INPUT = "invalid_input" PERMISSION_ERROR = "permission_error" # duplicate DEVELOPER_ERROR = "developer_error" + PORT_NOT_LISTENING = "port_not_listening" UNKNOWN = "unknown" @dataclass @@ -50,6 +52,7 @@ class ApiResponse: valid: bool error_type: Optional[ErrorType] = None data: Optional[Any] = None + failures: dict = field(default_factory=dict) message: Optional[str] = None backoff_strategy: BackoffStrategy = BackoffStrategy.NO_RETRY retry_now: bool = False # legacy diff --git a/core/services/networking/httpx/async_batch_requests.py b/core/services/networking/httpx/async_batch_requests.py new file mode 100644 index 0000000..958b17b --- /dev/null +++ b/core/services/networking/httpx/async_batch_requests.py @@ -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) diff --git a/core/services/networking/httpx/async_request.py b/core/services/networking/httpx/async_request.py new file mode 100644 index 0000000..509e791 --- /dev/null +++ b/core/services/networking/httpx/async_request.py @@ -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 diff --git a/core/services/networking/httpx/classify_response.py b/core/services/networking/httpx/classify_response.py index 04c8b8b..b365763 100644 --- a/core/services/networking/httpx/classify_response.py +++ b/core/services/networking/httpx/classify_response.py @@ -16,7 +16,7 @@ import json def classify_response(response) -> ApiResponse: 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) return ApiResponse( valid=False, diff --git a/core/services/networking/httpx/connect.py b/core/services/networking/httpx/connect.py new file mode 100644 index 0000000..86d167b --- /dev/null +++ b/core/services/networking/httpx/connect.py @@ -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} + diff --git a/core/services/networking/httpx/dns_resolver.py b/core/services/networking/httpx/dns_resolver.py new file mode 100644 index 0000000..1831a28 --- /dev/null +++ b/core/services/networking/httpx/dns_resolver.py @@ -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 diff --git a/core/services/networking/httpx/endpoints.py b/core/services/networking/httpx/endpoints.py new file mode 100644 index 0000000..90ea35f --- /dev/null +++ b/core/services/networking/httpx/endpoints.py @@ -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} + diff --git a/core/services/networking/httpx/httpx_client.py b/core/services/networking/httpx/httpx_client.py index dfdf7e0..c1989c3 100644 --- a/core/services/networking/httpx/httpx_client.py +++ b/core/services/networking/httpx/httpx_client.py @@ -1,10 +1,19 @@ """ 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.Constants import Constants import httpx from httpx_socks import AsyncProxyTransport @@ -12,8 +21,19 @@ import httpx_socks from httpx_socks import ProxyType _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: """ Purpose: @@ -58,6 +78,37 @@ def init_tor_session(port: int = 9050) -> bool: 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: """ @@ -78,9 +129,16 @@ def init_untested_tor(port: int = 9050) -> bool: def get_http_session() -> httpx.Client: """Return the global _http_client or raise RuntimeError.""" 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 +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(): """Close and reset the global _http_client.""" diff --git a/core/services/networking/httpx/make_request.py b/core/services/networking/httpx/make_request.py index 3f998d3..fd37e11 100644 --- a/core/services/networking/httpx/make_request.py +++ b/core/services/networking/httpx/make_request.py @@ -8,6 +8,7 @@ import asyncio import json import time import socket +from python_socks._errors import ProxyError def make_request( method: str, @@ -91,35 +92,53 @@ def _make_request( 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.RETRY_EXPONENTIAL) - else: - # Permanent DNS failure (bad domain) - return ApiResponse(valid=False, error_type=ErrorType.DNS_PERMANENT, backoff_strategy=BackoffStrategy.NO_RETRY) + if "Name or service not known" 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: + # # 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: # 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) - 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 - ) + 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( + valid=False, + error_type=ErrorType.TOR_NOT_WORKING, + message=f"Proxy error: {str(e)}", + backoff_strategy=BackoffStrategy.NO_RETRY + ) + print("this should not print") - except httpx.SSLError as e: - return ApiResponse( - valid=False, - error_type=ErrorType.NETWORK_ERROR, - message=f"SSL/certificate error: {str(e)}", - backoff_strategy=BackoffStrategy.NO_RETRY # Permanent cert issue - ) + + + # except httpx.SSLError as e: + # return ApiResponse( + # valid=False, + # 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: # Catches any other httpx request errors not covered above @@ -130,6 +149,15 @@ def _make_request( 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: if method == "get": diff --git a/core/services/networking/httpx/parallel_threading.py b/core/services/networking/httpx/parallel_threading.py index da84257..b529246 100644 --- a/core/services/networking/httpx/parallel_threading.py +++ b/core/services/networking/httpx/parallel_threading.py @@ -1,6 +1,8 @@ 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.api_requests.ApiResponseModel import ApiResponse, ErrorType, BackoffStrategy + from core.errors.logger import logger import time @@ -10,26 +12,10 @@ import httpx import httpx_socks # pip install httpx-socks 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 = { - "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: + # desired_endpoints = get_endpoints(wanted_list) start_total = time.time() with ThreadPoolExecutor(max_workers=5) as executor: @@ -40,7 +26,7 @@ def parallel_thread(client: httpx.Client, endpoints: dict = ENDPOINTS) -> dict: url=url, 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) @@ -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") 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 diff --git a/core/services/networking/tor_tools/pre_bootstrap.py b/core/services/networking/tor_tools/pre_bootstrap.py new file mode 100644 index 0000000..0f49dc7 --- /dev/null +++ b/core/services/networking/tor_tools/pre_bootstrap.py @@ -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. + diff --git a/core/services/networking/tor_tools/tor_dns.py b/core/services/networking/tor_tools/tor_dns.py new file mode 100644 index 0000000..6997a83 --- /dev/null +++ b/core/services/networking/tor_tools/tor_dns.py @@ -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 + diff --git a/core/services/networking/tor_tools/tor_orchestrator.py b/core/services/networking/tor_tools/tor_orchestrator.py index 198302e..fdd759c 100644 --- a/core/services/networking/tor_tools/tor_orchestrator.py +++ b/core/services/networking/tor_tools/tor_orchestrator.py @@ -23,7 +23,7 @@ def establish_tor_connection(observer: ConnectionObserver) -> ApiResponse: King Orchestrator 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. result = evaluate_pre_existing_tor(observer) diff --git a/pyproject.toml b/pyproject.toml index 5d4d055..5a65bc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sp-hydra-veil-core" -version = "2.5.4" +version = "2.5.5" authors = [ { name = "Simplified Privacy" }, ] @@ -46,6 +46,7 @@ dependencies = [ "typing_extensions==4.15.0", "urllib3==2.6.3", "httpx[http2]==0.28.1", + "httpx-socks==0.11.0", "dnspython==2.8.0", "socksio==1.0.0", ]