diff --git a/core/models/Result.py b/core/models/Result.py index 1c0f9c7..23cbad3 100644 --- a/core/models/Result.py +++ b/core/models/Result.py @@ -1,4 +1,4 @@ - +from core.services.networking.api_requests.ApiResponseModel import BackoffStrategy from enum import Enum from dataclasses import dataclass @@ -51,6 +51,9 @@ class ResultError(Enum): SERVER_SIDE = "server_side" MTU = "mtu" TOR_NEEDED = "tor_needed" + API_DOWN = "api_down" + NODE_ERROR = "node_error" + NO_INTERNET = "no_internet" NAVIGATE_TO = "navigate_to" UNKNOWN = "unknown" @@ -62,14 +65,15 @@ class Result(): data: Optional[Any] = None message: Optional[str] = None goal: Optional[str] = None + backoff_strategy: Optional[BackoffStrategy] = None function_choice: Optional[partial] = None no_option: Optional[partial] = None navigate_to: Optional[str] = None - def user_message(self) -> str: - """Human-readable error for the UI.""" - messages = { - ResultError.SUCCESS: "Operation completed successfully.", - ResultError.UNKNOWN: f"Error: {self.message}", - } - return messages.get(self.error_type, "Unknown error") + # def user_message(self) -> str: + # """Human-readable error for the UI.""" + # messages = { + # ResultError.SUCCESS: "Operation completed successfully.", + # ResultError.UNKNOWN: f"Error: {self.message}", + # } + # return messages.get(self.error_type, "Unknown error") diff --git a/core/models/manage/server_reply_tools.py b/core/models/manage/server_reply_tools.py index ca067e7..bd54956 100644 --- a/core/models/manage/server_reply_tools.py +++ b/core/models/manage/server_reply_tools.py @@ -118,7 +118,9 @@ def api_call_and_extraction( # EVALUATE CONNECTION FAILURE #----------------------------- # Layer 1: API failure: - if not api_response.valid: + print(f"api_response is {api_response}") + print(f"api_response is {api_response.data}") + if not api_response.valid and not api_response.error_type in (ErrorType.AUTHENTICATION_ERROR, ErrorType.INVALID_REQUEST): # legacy system if api_response.error_type == ErrorType.AUTHENTICATION_ERROR: diff --git a/core/models/pydantic_models/ServerReply.py b/core/models/pydantic_models/ServerReply.py index 02fb387..a37af6d 100644 --- a/core/models/pydantic_models/ServerReply.py +++ b/core/models/pydantic_models/ServerReply.py @@ -34,6 +34,7 @@ class ServerReply(): message: Optional[str] = None model_version: int = 1 data: Optional[dict] = None + boolean_result: Optional[bool] = None conf: Optional[str] = None payment_status: Optional[bool] = None diff --git a/core/observers/Notifications.py b/core/observers/Notifications.py index 9a4ec6c..0888e4e 100644 --- a/core/observers/Notifications.py +++ b/core/observers/Notifications.py @@ -9,6 +9,7 @@ class EventType(Enum): PROFILE_CREATED = auto() PROFILE_DELETED = auto() STATUS_UPDATE = auto() + SHOW_POPUP = auto() RESUME_ACTIONS = auto() class Notifications: diff --git a/core/services/keys_and_verifications/wireguard_keys.py b/core/services/keys_and_verifications/wireguard_keys.py index a096f1c..b1c6316 100644 --- a/core/services/keys_and_verifications/wireguard_keys.py +++ b/core/services/keys_and_verifications/wireguard_keys.py @@ -1,4 +1,4 @@ - +from core.utils.get_raw_string import get_string from core.models.manage.server_reply_tools import api_call_and_extraction, ApiCall # from core.services.networking.httpx import connect #from core.services.networking.api_requests.ApiResponseModel import ErrorType @@ -118,6 +118,62 @@ def insert_mtu(mtu: int, wireguard_config: str) -> str: return re.sub(pattern, replacement, wireguard_config) +def insert_mtu_with_replacement(mtu: int, wireguard_config: str) -> str: + # 1. Attempt to replace an existing MTU line. + # ^MTU matches "MTU" at the start of a line (requires re.MULTILINE). + # \s*=\s*\d+ matches the equals sign and the numeric value. + new_config, count = re.subn( + r"^MTU\s*=\s*\d+", + f"MTU = {mtu}", + wireguard_config, + flags=re.MULTILINE + ) + + # If count > 0, an existing MTU was found and replaced. + if count > 0: + return new_config + + # 2. If no MTU existed, use the original insertion logic. + pattern = r"(DNS\s*=\s*[^\n]+)(\s*)(\[Peer\])" + replacement = rf"\1\nMTU = {mtu}\2\3" + + return re.sub(pattern, replacement, new_config) + + +def insert_mtu_into_config_on_disk(profile: Profile, mtu: int) -> bool: + """Inserts an MTU into a Systemwide Profile""" + file_location = f"/etc/hydra-veil/profiles/{str(profile.id)}/wg.conf" + wireguard_config = get_string(file_location) + new_config = insert_mtu_with_replacement(mtu=mtu, wireguard_config=wireguard_config) + try: + profile.attach_wireguard_configuration(new_config) + return True + except Exception as e: + logger.error({str(e)}) + return False + +def _parse_for_mtu(wireguard_config: str) -> Optional[int]: + # This pattern searches for 'MTU', optional whitespace, an equals sign, + # and captures the digits that follow. + pattern = r"MTU\s*=\s*(\d+)" + + match = re.search(pattern, wireguard_config) + + if match: + # Return the captured digit group as an integer. + return int(match.group(1)) + + return None + + +# ASSUMES SYSTEM-WIDE: +def get_mtu(profile_id: int) -> Optional[int]: + """Gets the MTU of a Systemwide Profile""" + file_location = f"/etc/hydra-veil/profiles/{profile_id}/wg.conf" + wireguard_config = get_string(file_location) + return _parse_for_mtu(wireguard_config) + + # If need it pretty spacing quickly: def expand_newlines(text): """ @@ -125,67 +181,3 @@ def expand_newlines(text): empty line between every non-empty line of text. """ return "\n\n".join([line for line in text.splitlines() if line.strip()]) - - - - - -# over-engineering: - -# api_result = connect.single_endpoint( -# method="post", -# url=url, -# observer=connection_observer, -# payload=payload, -# billing_code=billing_code -# ) - -# if not api_result.valid: -# if api_result.error_type == ErrorType.AUTHENTICATION_ERROR: -# logger.error(f"Server is giving an Authentication error for billing ID: {billing_code}") -# raise InvalidSubscriptionError(f"Invalid Subscription for {billing_code}") -# else: -# error_msg = f"Could Not Connect to API: {api_result.error_type}" -# logger.error(error_msg) -# raise ConnectionError(error_msg) - -# # 2nd Layer Translation: -# result = format_server_response(api_response=api_result, desired_type="data") -# if not result.valid: -# # KILLING THE RESULT FOR NOW. Later, replace this error raise with the return of data, -# raise InvalidSubscriptionError(result.message) - - -# print(f"wireguard_configuration type is {type(wireguard_configuration)}") -# # print(f"wireguard_configuration value is {wireguard_configuration}") - -# if isinstance(wireguard_configuration, dict): -# print(f"wireguard_configuration is {type(wireguard_configuration)}") -# print(f"wireguard_configuration is {wireguard_configuration}") -# logger.error("Server isn't returning a WG config, it's trying to tell you a ServerReply message...") - # return format_server_response(wireguard_configuration, "message") - - -# def handle_config_error(wireguard_configuration: str|dict) -> Result: -# """The server gave the wireguard config in an invalid format. First convert it to get the error.""" -# server_reply = format_server_response(wireguard_configuration, "message") -# if server_reply.valid: -# return server_reply -# else: -# return Result(valid=False, error_type=ResultError.NODE_ERROR, message="server-side error with wireguard config format.") - - -# legacy: - -# country_code = profile.location.country_code -# location_code = profile.location.code -# url = f'{Constants.SP_API_BASE_URL}/countries/{country_code}/locations/{location_code}/wireguard-sessions' - -# ConnectionController.with_preferred_connection( -# profile.location.country_code, -# profile.location.code, -# profile.subscription.billing_code, -# wireguard_keys.get('public'), -# task=WebServiceApiService.post_wireguard_session, -# connection_observer=connection_observer -# ) diff --git a/core/services/networking/api_requests/ApiResponseModel.py b/core/services/networking/api_requests/ApiResponseModel.py index 87b9f71..2c72cec 100644 --- a/core/services/networking/api_requests/ApiResponseModel.py +++ b/core/services/networking/api_requests/ApiResponseModel.py @@ -14,6 +14,8 @@ class BackoffStrategy(Enum): class ErrorType(Enum): """Classified error categories.""" SUCCESS = "success" + APPLICATION_ERROR = "application_error" + INVALID_ENDPOINT = "invalid_endpoint" # 404 - could be wrong URL or middleware rejecting INVALID_REQUEST = "invalid_request" # 400, 405 - bad method/body AUTHENTICATION_ERROR = "authentication_error" # 401 - need credentials diff --git a/core/services/networking/general_connection_tools/config_tools.py b/core/services/networking/general_connection_tools/config_tools.py index 70f5802..8549183 100644 --- a/core/services/networking/general_connection_tools/config_tools.py +++ b/core/services/networking/general_connection_tools/config_tools.py @@ -9,7 +9,7 @@ def extract_endpoint_ip(profile_id: str, profile_type: Optional[ProfileType] = N profile_path = Constants.HV_PROFILE_CONFIG_HOME + f'/{profile_id}' - if not profile_type: # assumes systemwide: + if not profile_type or profile_type == ProfileType.SYSTEM: wg_conf_path = f'{profile_path}/wg.conf.bak' elif profile_type == ProfileType.SESSION: wg_conf_path = f'{profile_path}/wg.conf' diff --git a/core/services/networking/general_connection_tools/connection_enable.py b/core/services/networking/general_connection_tools/connection_enable.py index 158494d..0398191 100644 --- a/core/services/networking/general_connection_tools/connection_enable.py +++ b/core/services/networking/general_connection_tools/connection_enable.py @@ -1,3 +1,5 @@ +from core.services.networking.general_connection_tools.location_evaluation import check_if_location_is_up +from core.services.networking.api_requests.ApiResponseModel import BackoffStrategy from core.services.networking.general_connection_tools.testing_evaluating import system_uses_wireguard_interface from core.services.networking.systemwide.systemwide_wireguard import establish_system_connection, terminate_system_connection from core.services.keys_and_verifications.wireguard_keys import register_wireguard_session @@ -197,6 +199,9 @@ def _ensure_wireguard_configured( logger.info(f"[CONNECTION] Profile does NOT have WG keys and it needs it. Registering a NEW wg session..") registry_result = register_wireguard_session(profile, connection_observer=connection_observer) + logger.info(f"registry_result is {registry_result}") + if not registry_result.valid: + return registry_result def _establish_with_renegotiation( profile: Union[SessionProfile, SystemProfile], @@ -209,7 +214,8 @@ def _establish_with_renegotiation( try: logger.info(f"[CONNECTION] Connecting..") return establish_fn(profile, ignore=ignore, connection_observer=connection_observer) - except ConnectionError: + except ConnectionError as e: + logger.error(str(e)) if __should_renegotiate(profile): logger.info(f"[CONNECTION] Renegotiating a new wg key session with API..") @@ -245,7 +251,6 @@ def __should_renegotiate(profile: Union[SessionProfile, SystemProfile]): - # Enums # if profile.type == ProfileType.SYSTEM: # print("This is a system profile") diff --git a/core/services/networking/general_connection_tools/ip.py b/core/services/networking/general_connection_tools/ip.py index 35aac28..807977b 100644 --- a/core/services/networking/general_connection_tools/ip.py +++ b/core/services/networking/general_connection_tools/ip.py @@ -15,10 +15,11 @@ import httpx from httpx_socks import AsyncProxyTransport import httpx_socks from httpx_socks import ProxyType - +import subprocess from typing import Optional API_ENDPOINT_FORMATS = { + f"{Constants.SP_BASE_URL}/v2/get_ip": 'ip', "https://api.ipify.org?format=json": "ip", "https://api.myip.com": "ip", "https://wtfismyip.com/json": "YourFuckingIPAddress", @@ -106,3 +107,24 @@ def extract_ip_from_response(response: dict, api_url: str) -> str | bool: final_value = response[desired_key] return final_value + +def ping_ip(ip: str) -> Result: + command = ["ping", "-c", "1", "-W", "2", ip] + human_readable_goal = f"Pinging {ip}" + return run_generic_command(command, human_readable_goal, timeout=5) + + + +def ping_ip_worked(ip: str) -> bool: + try: + # -c 1: send only 1 packet + # -W 2: wait 2 seconds for a response + output = subprocess.run( + ["ping", "-c", "1", "-W", "2", ip], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + return output.returncode == 0 + except Exception: + return False \ No newline at end of file diff --git a/core/services/networking/general_connection_tools/location_evaluation.py b/core/services/networking/general_connection_tools/location_evaluation.py new file mode 100644 index 0000000..5869fb0 --- /dev/null +++ b/core/services/networking/general_connection_tools/location_evaluation.py @@ -0,0 +1,88 @@ +from core.services.networking.api_requests.ApiResponseModel import BackoffStrategy +from core.services.networking.general_connection_tools.test_internet import test_internet +from core.services.networking.general_connection_tools.ip import ping_ip_worked +from core.services.networking.systemwide.general_tools.test_any_protocol import get_singbox_ip +from core.services.networking.general_connection_tools.config_tools import extract_endpoint_ip +from core.models.manage.server_reply_tools import api_call_and_extraction, ApiCall +from core.models.BaseProfile import BaseProfile as Profile +from core.models.BaseProfile import ProfileType +from core.observers.ConnectionObserver import ConnectionObserver +from core.Constants import Constants +from core.errors.logger import logger +from core.models.Result import Result, ResultError +from core.utils.type_checker import type_checker +from core.models.enum_models import ProtocolChoice + + +@type_checker +def get_profile_ip(profile_id: int, protocol: ProtocolChoice, profile_type: ProfileType) -> str|None|bool: + if protocol == ProtocolChoice.WIREGUARD: + print("getting wireguard IP") + return extract_endpoint_ip(profile_id=profile_id, profile_type=profile_type) + + elif protocol in (ProtocolChoice.VLESS, ProtocolChoice.HYSTERIA2) and profile_type == ProfileType.SYSTEM: + return get_singbox_ip(profile_id=profile_id) + else: + logger.error("Singbox profiles not yet offered for session profiles") + return None + +@type_checker +def check_if_location_is_up(profile: Profile, connection_observer: ConnectionObserver) -> Result: + + profile_id = profile.id + location_id = profile.location.id + location_name = profile.location.country_name + profile_type = profile.type + string_protocol = profile.connection.code + protocol = ProtocolChoice(string_protocol) + + print(f""" + profile_id: {profile_id} + location_id: {location_id} + location_name: {location_name} + profile_type: {profile_type} + protocol: {protocol} + """) + + ip_address = get_profile_ip(profile_id=profile_id, protocol=protocol, profile_type=profile_type) + + if ip_address: + can_be_pinged = ping_ip_worked(ip_address) + print(f"Can {location_name} be pinged? {can_be_pinged}") + else: + logger.error("Skipping IP Address Test.") + can_be_pinged = False + + if not can_be_pinged: + do_we_have_internet = test_internet() + print(f"Do you have the internet? {do_we_have_internet}") + + if not do_we_have_internet: + error_msg = "Basic Internet Connection Issues" + return Result(valid=False, backoff_strategy=BackoffStrategy.NO_RETRY, error_type=ResultError.NO_INTERNET, message=error_msg) + + # Now check vs official API: + url = f'{Constants.SP_BASE_URL}/v2/check_location' + payload = { + 'location_id': str(location_id) + } + + result = api_call_and_extraction( + desired_type="boolean_result", + method=ApiCall.POST, + url=url, + connection_observer=connection_observer, + payload=payload + ) + + if not result.valid and not can_be_pinged: + error_msg = "Can't ping the VPN node or even reach the API. Your connection is unstable." + return Result(valid=False, backoff_strategy=BackoffStrategy.NO_RETRY, error_type=ResultError.NO_INTERNET, message=error_msg) + + location_up = result.data + + if location_up: + return Result(valid=True, backoff_strategy=BackoffStrategy.RETRY_IMMEDIATE) + else: + error_msg = f"The {location_name} node is currently down." + return Result(valid=False, backoff_strategy=BackoffStrategy.NO_RETRY, error_type=ResultError.NODE_ERROR, message=error_msg) diff --git a/core/services/networking/general_connection_tools/test_internet.py b/core/services/networking/general_connection_tools/test_internet.py new file mode 100644 index 0000000..4b4670b --- /dev/null +++ b/core/services/networking/general_connection_tools/test_internet.py @@ -0,0 +1,50 @@ +from core.errors.logger import logger + +import httpx +import asyncio +import httpx_socks +from httpx_socks import ProxyType +from typing import Optional + +DEFAULT_URLS = [ + "https://www.duckduckgo.com", + "https://www.github.com", + "https://www.wikipedia.org", + "https://www.grapheneos.org" +] + +async def _async_test_internet(urls, port: Optional[int] = None): + # Configure transport if a port is provided for the SOCKS5 proxy + transport = None + if port: + transport = httpx_socks.AsyncProxyTransport( + proxy_type=ProxyType.SOCKS5, + proxy_host="127.0.0.1", + proxy_port=port, + ) + + # Use the transport in the AsyncClient if it was created + async with httpx.AsyncClient(transport=transport, http2=True, timeout=6) as client: + tasks = [asyncio.create_task(client.get(url)) for url in urls] + + done, pending = await asyncio.wait( + tasks, + return_when=asyncio.FIRST_COMPLETED, + timeout=6 + ) + + for task in pending: + task.cancel() + + for task in done: + try: + response = task.result() + logger.info(f"✓ Got response: {response.status_code}") + return True + except Exception as e: + logger.error(f"✗ Failed: {e}") + + return False + +def test_internet(port: Optional[int] = None): + return asyncio.run(_async_test_internet(DEFAULT_URLS, port)) diff --git a/core/services/networking/httpx/classify_response.py b/core/services/networking/httpx/classify_response.py index b365763..6bb0793 100644 --- a/core/services/networking/httpx/classify_response.py +++ b/core/services/networking/httpx/classify_response.py @@ -26,6 +26,7 @@ def classify_response(response) -> ApiResponse: ) logger.info(f"Classifying HTTP response: {response.status_code}") + logger.info(f"payload: {response.json()}") # Success case (2xx) if 200 <= response.status_code < 300: @@ -49,13 +50,22 @@ def classify_response(response) -> ApiResponse: if 400 <= response.status_code < 500: try: resp_json = response.json() + if "valid" in resp_json and "error_type" in resp_json: + return ApiResponse( + valid=True, + error_type=ErrorType.APPLICATION_ERROR, + data=resp_json, + message="Server replied with an error.", + backoff_strategy=BackoffStrategy.NO_RETRY + ) + except ValueError: + error_msg = response.text + error_msg = ( resp_json.get("message") or resp_json.get("error") or resp_json.get("error_code") ) - except ValueError: - error_msg = response.text # Map status codes to semantic error types and retry strategies error_map = { diff --git a/core/services/networking/systemwide/encrypted_proxy/singbox_runner.py b/core/services/networking/systemwide/encrypted_proxy/singbox_runner.py index 173847f..6fb7a42 100644 --- a/core/services/networking/systemwide/encrypted_proxy/singbox_runner.py +++ b/core/services/networking/systemwide/encrypted_proxy/singbox_runner.py @@ -350,6 +350,8 @@ def start_singbox( # raises error if not okay. Notifications.emit(EventType.SYSTEM_ENABLE, profile_id) + notification = "Connected." + Notifications.emit(EventType.STATUS_UPDATE, notification) # ============= CONCLUSION ============= return Result(valid=True, data=process_id) diff --git a/core/services/networking/systemwide/systemwide_wireguard.py b/core/services/networking/systemwide/systemwide_wireguard.py index e987292..149695b 100644 --- a/core/services/networking/systemwide/systemwide_wireguard.py +++ b/core/services/networking/systemwide/systemwide_wireguard.py @@ -1,6 +1,10 @@ +from core.services.keys_and_verifications.wireguard_keys import get_mtu, insert_mtu_into_config_on_disk +from core.services.networking.systemwide.wireguard.mtu_tools import loop_through_testing_mtu, mtu_is_fine, MIN_MTU, MAX_MTU +from core.services.networking.general_connection_tools.location_evaluation import check_if_location_is_up +from core.services.networking.api_requests.ApiResponseModel import BackoffStrategy from core.models.enum_models import ProtocolChoice, SINGBOX_PROTOCOLS from core.controllers.profile_state.get_profile_data import get_protocol_enum -from core.services.networking.general_connection_tools.testing_evaluating import await_connection, system_uses_wireguard_interface, terminate_tor_connection +from core.services.networking.general_connection_tools.testing_evaluating import system_uses_wireguard_interface, terminate_tor_connection from core.services.keys_and_verifications.endpoint_verification import verify_wireguard_endpoint from core.models.Result import Result, ResultError from core.services.networking.systemwide.systemwide_utils import extract_wg_interface_name, check_system_prereqs @@ -11,30 +15,31 @@ from core.services.networking.systemwide.general_tools.test_any_protocol import # from core.services.networking.general_connection_tools.config_tools import extract_endpoint_ip, extract_dns from core.services.networking.systemwide import killswitch from core.services.networking.systemwide.dns_tools.process_dns_result import orchestrate_dns_check -from core.services.networking.systemwide.dns_tools.SearchResult import SearchResult, SearchError +# from core.services.networking.systemwide.dns_tools.SearchResult import SearchResult, SearchError from core.services.networking.systemwide.wireguard.nmcli_tools import setup_nmcli_connection, setup_ipv6_sinkhole, manual_nmcli_enable -from core.services.networking.systemwide.wireguard.wg_firewall_dns import set_dns, revert_dns, turn_on_firewall, check_and_kill_firewall, enable_firewall_with_retry +from core.services.networking.systemwide.wireguard.wg_firewall_dns import set_dns, revert_dns, check_and_kill_firewall, enable_firewall_with_retry from core.errors.logger import logger -from core.errors.exceptions import FirewallError +# from core.errors.exceptions import FirewallError from core.utils.run_commands import run_generic_command from core.observers.TicketObserver import TicketObserver -from core.Constants import Constants +# from core.Constants import Constants from core.Errors import ConnectionTerminationError, CommandNotFoundError from core.controllers.ConfigurationController import ConfigurationController from core.controllers.SystemStateController import SystemStateController -from core.models.BaseProfile import ProfileType -from core.models.session.SessionProfile import SessionProfile +# from core.models.BaseProfile import ProfileType +# from core.models.session.SessionProfile import SessionProfile from core.models.system.SystemProfile import SystemProfile from core.models.system.SystemState import SystemState from core.observers.ConnectionObserver import ConnectionObserver from subprocess import CalledProcessError from typing import Optional -import os -import re +# import os +# import re import shutil -import subprocess +# import subprocess import time +import asyncio def terminate_system_connection( firewall_setting: Optional[bool] = None, @@ -53,12 +58,12 @@ def terminate_system_connection( # ====== FIREWALL ======= if firewall_setting: - disable_result_object = killswitch.disarm() # raw blind kill + _ = killswitch.disarm() # raw blind kill # ====== DNS ======= if dns_setting: - reverting_dns_worked = revert_dns() - + _ = revert_dns() + # ====== NMCLI ======= if SystemStateController.exists(): @@ -98,6 +103,54 @@ def terminate_system_connection( +def evaluate_location_and_internet(profile: SystemProfile, connection_observer: ConnectionObserver) -> Result: + """Checks if location is up and returns the backoff strategy.""" + notification = "Checking If Location is Up.." + Notifications.emit(EventType.STATUS_UPDATE, notification) + logger.info(notification) + + location_result = check_if_location_is_up(profile=profile, connection_observer=connection_observer) + + if location_result.backoff_strategy == BackoffStrategy.RETRY_IMMEDIATE: + Notifications.emit(EventType.STATUS_UPDATE, "Retrying after Location Check") + + return location_result + +def _cleanup_on_error(firewall_setting: bool, dns_setting: bool) -> None: + """Safely terminate connection on error, swallowing cleanup errors.""" + try: + terminate_system_connection(firewall_setting, dns_setting) + except ConnectionTerminationError: + pass + + +def _finish_function(final_result: Result) -> Result: + terminate_tor_connection() + time.sleep(0.3) + return final_result + + +def changed_mtu(profile: SystemProfile, current_mtu: Optional[int]) -> bool: + notification = "Testing MTU.." + Notifications.emit(EventType.STATUS_UPDATE, notification) + + # optimal_mtu = loop_through_testing_mtu() + optimal_mtu = asyncio.run(loop_through_testing_mtu()) + logger.info(f"Optimal MTU is {optimal_mtu}") + + if optimal_mtu == current_mtu: + return False + + # Assuming it isn't already at the max, there's no need to reset it to the max, + if current_mtu != MAX_MTU and optimal_mtu == MAX_MTU: + return False + + notification = f"Updating WG MTU with {optimal_mtu}" + Notifications.emit(EventType.STATUS_UPDATE, notification) + return insert_mtu_into_config_on_disk(profile=profile, mtu=optimal_mtu) + + + def __establish_system_connection( profile: SystemProfile, firewall_setting: bool = False, @@ -118,12 +171,16 @@ def __establish_system_connection( 8) Testing, confirming, displaying to the end user. """ check_system_prereqs() - logger.info("[CONNECT] Terming existing connections..") + notification = "Terming existing connections.." + Notifications.emit(EventType.STATUS_UPDATE, notification) + logger.info(notification) terminate_system_connection(firewall_setting, dns_setting) # ============= INITIAL NMCLI CONNECTION ============= - if connection_observer: - connection_observer.notify("connecting", "Setting Up WireGuard..") + notification = "Setting Up WireGuard.." + Notifications.emit(EventType.STATUS_UPDATE, notification) + # if connection_observer: + # connection_observer.notify("connecting", notification) wg_config_path = profile.get_wireguard_configuration_path() nmcli_result = setup_nmcli_connection(wg_config_path) @@ -147,7 +204,7 @@ def __establish_system_connection( # ============= SETUP STATE ============= # Even if firewall is off, we want to save the fact we turned on the VPN tunnel, before we raise errors. - logger.info(f"Setting State JSON..") + logger.info("Setting State JSON..") current_state = SystemStateController.create( profile_id=profile.id, firewalled=firewall_setting, # intended setting, not result yet @@ -200,15 +257,19 @@ def __establish_system_connection( # ============= TESTING ============= logger.info("If we made it this far, we have nmcli on, ipv6 sinkhole on, firewall on.") - if connection_observer: - connection_observer.notify("connecting", "Testing Connection Health..") + # if connection_observer: + # connection_observer.notify("connecting", "Testing Connection Health..") + notification = "Testing IP address.." + Notifications.emit(EventType.STATUS_UPDATE, notification) test_result = test_ip_systemwide_for_any_protocol( profile_id=profile.id, protocol=ProtocolChoice.WIREGUARD, connection_observer=connection_observer ) if not test_result.valid: + notification = "Test Failed." + Notifications.emit(EventType.STATUS_UPDATE, notification) return test_result # ============= UPDATE STATE ============= @@ -221,65 +282,11 @@ def __establish_system_connection( connection_observer.notify("connected", "Confirmed") Notifications.emit(EventType.SYSTEM_ENABLE, profile.id) + notification = "Connected." + Notifications.emit(EventType.STATUS_UPDATE, notification) return Result(valid=True, data=current_state) - -def evaluate_connection_result(connection_result: Result) -> Result: - """In the future, this will raise other error types or filter replies.""" - if not connection_result.valid: - logger.error(f"[SYSTEMWIDE WG] Critical issue, could not establish a connection: {connection_result.message}") - time.sleep(1) - raise ConnectionError(f"Connection validation failed: {connection_result.message}") - else: - return connection_result - - -def _cleanup_on_error(firewall_setting: bool, dns_setting: bool) -> None: - """Safely terminate connection on error, swallowing cleanup errors.""" - try: - terminate_system_connection(firewall_setting, dns_setting) - except ConnectionTerminationError: - pass - - -def _establish_connection_with_retry( - profile: SystemProfile, - firewall_setting: bool, - dns_setting: bool, - connection_observer: Optional[ConnectionObserver], - max_retries: int = 2 -) -> Result: - """Establish connection with retry logic for transient errors.""" - last_error = None - - for attempt in range(1, max_retries): - if connection_observer: - connection_observer.notify("connecting", f"Connecting attempt {attempt} of {max_retries}.. ") - try: - connection_result = __establish_system_connection( - profile=profile, - firewall_setting=firewall_setting, - dns_setting=dns_setting, - connection_observer=connection_observer - ) - evaluate_connection_result(connection_result) - return connection_result - - except ConnectionError as e: - # Unrecoverable error, fail immediately - logger.error(str(e)) - raise - - except CalledProcessError as e: - # Potentially transient, retry once - last_error = e - logger.warning(f"Connection attempt {attempt}/{max_retries} failed with CalledProcessError: {e}") - if attempt == max_retries: - raise ConnectionError('The connection could not be established.') from e - - - def establish_system_connection( profile: SystemProfile, ignore: tuple[type[Exception]] = (), @@ -287,13 +294,13 @@ def establish_system_connection( ticket_observer: Optional[TicketObserver] = None # Passed in only for assassin's link to tickets in ConnectionController.establish_session_connection ): """ - Purpose: - Calls the private function with the full flow, so it can be retried on failure. + Establish connection with a maximum of 2 retries. Steps) 1) Verify Endpoint's encryption - 2) Pass to private function - 3) Retry on failure + 2) get settings + 3) Attempt 1 -> (Fail) -> Location Check + 4) Attempt 2 -> (Fail) -> MTU Check -> Attempt 3 """ # ================= VERIFY ENDPOINT ================= if connection_observer: @@ -306,17 +313,88 @@ def establish_system_connection( dns_setting = ConfigurationController.get_dns_setting() # ================= CONNECT WG ================ - try: - final_result = _establish_connection_with_retry( - profile=profile, - firewall_setting=firewall_setting, - dns_setting=dns_setting, - connection_observer=connection_observer - ) - except (ConnectionError, CalledProcessError): - _cleanup_on_error(firewall_setting, dns_setting) - raise + # We allow 3 total attempts (Initial + 2 retries) + for attempt in range(1, 4): + try: + if connection_observer: + connection_observer.notify("connecting", f"Connecting attempt {attempt} of 3.. ") - terminate_tor_connection() - time.sleep(0.3) - return final_result \ No newline at end of file + # Attempt the actual system connection + connection_result = __establish_system_connection( + profile=profile, + firewall_setting=firewall_setting, + dns_setting=dns_setting, + connection_observer=connection_observer + ) + + if connection_result.valid: + return _finish_function(connection_result) + + # If not valid, we treat it as a failure to trigger the except block logic + raise ConnectionError("Connection result invalid") + + except (ConnectionError, CalledProcessError) as e: + _cleanup_on_error(firewall_setting, dns_setting) + + if attempt == 1: + # First failure: Evaluate Location and Internet without connection, + terminate_system_connection(firewall_setting, dns_setting) + location_result = evaluate_location_and_internet( + profile=profile, + connection_observer=connection_observer + ) + + if location_result.backoff_strategy == BackoffStrategy.NO_RETRY: + logger.error(f"Location check failed: {location_result.message}") + bottom_left = "Node Down" + Notifications.emit(EventType.STATUS_UPDATE, bottom_left) + notification = location_result.message + Notifications.emit(EventType.SHOW_POPUP, notification) + return location_result + # raise ConnectionError(location_result.message) from e + + notification = "Location check passed. Retrying immediately..." + Notifications.emit(EventType.STATUS_UPDATE, notification) + logger.info(notification) + continue # Proceed to attempt 2 + + elif attempt == 2: + # Second failure: Evaluate MTU + current_mtu = get_mtu(profile_id=profile.id) + + if current_mtu != MAX_MTU or not current_mtu: + logger.info("MTU is already at the max, no point to change it.") + raise ConnectionError("Failed to connect.") + # return Result(valid=False, error_type=ResultError.CONNECTION, message="Failed to connect.") + + if not current_mtu or current_mtu > MIN_MTU: + notification = "Testing MTU..." + Notifications.emit(EventType.STATUS_UPDATE, notification) + logger.info(notification) + if mtu_is_fine(current_mtu): + notification = "MTU is NOT the issue." + Notifications.emit(EventType.STATUS_UPDATE, notification) + logger.info(notification) + # If MTU wasn't changed or wasn't applicable, don't bother with attempt 3 + raise ConnectionError("Failed to connect.") + # return Result(valid=False, error_type=ResultError.CONNECTION, message="Failed to connect.") + + if changed_mtu(profile=profile, current_mtu=current_mtu): + logger.info("MTU changed. Retrying connection...") + continue # Proceed to attempt 3 + else: + logger.warning("MTU check performed but no change was made or user rejected it.") + notification = "MTU was NOT changed." + Notifications.emit(EventType.STATUS_UPDATE, notification) + else: + logger.info("Skipping MTU testing, it's not relevant here.") + + # If MTU wasn't changed or wasn't applicable, don't bother with attempt 3 + raise ConnectionError("Failed to connect.") + # return Result(valid=False, error_type=ResultError.CONNECTION, message="Failed to connect.") + + else: + # Attempt 3 failed + logger.error("Maximum retries reached. Connection failed.") + raise ConnectionError("Failed to connect.") + # return Result(valid=False, error_type=ResultError.CONNECTION, message="Failed to connect.") diff --git a/core/services/networking/systemwide/wireguard/mtu_tools.py b/core/services/networking/systemwide/wireguard/mtu_tools.py index 4c1c1ff..579ca50 100644 --- a/core/services/networking/systemwide/wireguard/mtu_tools.py +++ b/core/services/networking/systemwide/wireguard/mtu_tools.py @@ -1,16 +1,21 @@ +from core.observers.Notifications import Notifications, EventType from core.utils.run_commands import run_generic_command from core.models.Result import Result, ResultError from core.errors.logger import logger -MAX_MTU = 1500 +from typing import Optional +import asyncio + +MAX_MTU = 1420 MIN_MTU = 1280 # Use integer division for the middle point MIDDLE_MTU = MIN_MTU + ((MAX_MTU - MIN_MTU) // 2) # Fallback list for reliability -URLS = ["217.156.66.35", "185.165.169.91", "9.9.9.9"] +URLS = ["9.9.9.9", "217.156.66.35", "185.165.169.91"] -def test_an_mtu(mtu_value: int, url: str, timeout: int) -> Result: + +def test_mtu_without_async(mtu_value: int, url: str, timeout: int) -> Result: # Payload size = MTU - 28 bytes (IP + ICMP headers) payload_size = mtu_value - 28 if payload_size < 0: @@ -20,67 +25,108 @@ def test_an_mtu(mtu_value: int, url: str, timeout: int) -> Result: human_readable_goal = f"Test the MTU value {mtu_value} against {url}." return run_generic_command(command, human_readable_goal, timeout=timeout) -def find_optimal_mtu(low: int, high: int, url: str) -> int: - """ - Performs a binary search between a known working MTU (low) - and a known failing MTU (high). - """ + +def mtu_is_fine(current_mtu: Optional[int] = None) -> bool: + if current_mtu: + testing_mtu = current_mtu + else: + testing_mtu = MAX_MTU + + notification = f"Testing MTU {testing_mtu}..." + Notifications.emit(EventType.STATUS_UPDATE, notification) + + for url in URLS: + result = test_mtu_without_async(mtu_value=testing_mtu, url=url, timeout=6) + if result.valid: + return True + return False + + +async def test_an_mtu(mtu_value: int, url: str, timeout: int) -> Result: + payload_size = mtu_value - 28 + if payload_size < 0: + return Result(valid=False) + + try: + # Use asyncio subprocess for non-blocking OS calls + proc = await asyncio.create_subprocess_exec( + "ping", "-M", "do", "-s", str(payload_size), "-c", "1", url, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL + ) + + # Wait for completion or timeout + await asyncio.wait_for(proc.wait(), timeout=timeout) + return Result(valid=(proc.returncode == 0)) + + except (asyncio.TimeoutError, Exception): + if 'proc' in locals(): + try: + proc.kill() + except ProcessLookupError: + pass + return Result(valid=False) + +async def find_optimal_mtu_binary_search(low: int, high: int, url: str) -> int: optimal = low current_timeout = 6 while low <= high: mid = (low + high) // 2 - # We increase timeout slightly as we try larger packets - # to avoid false negatives due to congestion/latency - logger.info(f"Refining: Testing MTU {mid}...") - result = test_an_mtu(mid, url, timeout=current_timeout) + notification = f"Binary searching: Testing MTU {mid} against {url}..." + Notifications.emit(EventType.STATUS_UPDATE, notification) + logger.info(notification) + + result = await test_an_mtu(mid, url, timeout=current_timeout) if result.valid: optimal = mid low = mid + 1 - current_timeout += 1 # Increase timeout as we get closer to max + current_timeout += 1 else: high = mid - 1 return optimal -def loop_through_testing_mtu(): - # The sequence to probe the range +async def loop_through_testing_mtu(): INITIAL_TEST_LIST = [MAX_MTU, MIDDLE_MTU, MIN_MTU] - for url in URLS: - logger.info(f"Testing connectivity using URL: {url}") + for each_mtu in INITIAL_TEST_LIST: + notification = f"Probing MTU {each_mtu} across all IPs..." + Notifications.emit(EventType.STATUS_UPDATE, notification) + logger.info(notification) + + # Create tasks for all URLs for the current MTU value + async def wrapped_test(u): + res = await test_an_mtu(each_mtu, u, timeout=6) + return res, u + + tasks = [asyncio.create_task(wrapped_test(url)) for url in URLS] - working_mtu = None - failed_above = None - - for each_mtu in INITIAL_TEST_LIST: - logger.info(f"Probing MTU {each_mtu}...") - result = test_an_mtu(mtu_value=each_mtu, url=url, timeout=6) - - if result.valid: - working_mtu = each_mtu - # If MAX_MTU works immediately, we've found the ceiling - if each_mtu == MAX_MTU: - return MAX_MTU - break - - if working_mtu: - # We found a baseline. Now we need to find the ceiling. - # The 'high' is the first value in our test list that failed. - # If MIDDLE worked, high is MAX. If MIN worked, high is MIDDLE. - if working_mtu == MIDDLE_MTU: - failed_above = MAX_MTU - elif working_mtu == MIN_MTU: - failed_above = MIDDLE_MTU - else: - failed_above = MAX_MTU + 1 - - logger.info(f"Baseline found at {working_mtu}. Searching for optimal...") - return find_optimal_mtu(working_mtu, failed_above, url) - - # If we reach here, the MIN_MTU failed for this URL - logger.error(f"Could not reach {url} even with minimum MTU. Trying next URL.") + try: + # Process results as they come in + for coro in asyncio.as_completed(tasks): + result, url = await coro + if result.valid: + # Scenario A: Maximum possible MTU worked immediately + if each_mtu == MAX_MTU: + logger.info(f"MAX_MTU {MAX_MTU} worked on {url}!") + return MAX_MTU + + # Scenario B: We found a baseline, now binary search for the ceiling + if each_mtu == MIDDLE_MTU: + failed_above = MAX_MTU + else: # MIN_MTU + failed_above = MIDDLE_MTU + + logger.info(f"Baseline found at {each_mtu} on {url}. Searching for optimal...") + return await find_optimal_mtu_binary_search(each_mtu, failed_above, url) + finally: + # Kill all other pending pings for this MTU level immediately + for t in tasks: + if not t.done(): + t.cancel() logger.critical("All URLs failed MTU tests. Check your WireGuard connection.") return None + diff --git a/core/services/payment_phase/payment_update_tools.py b/core/services/payment_phase/payment_update_tools.py index 14b1ba2..580c6a4 100644 --- a/core/services/payment_phase/payment_update_tools.py +++ b/core/services/payment_phase/payment_update_tools.py @@ -7,6 +7,7 @@ from core.Constants import Constants from core.models.Result import Result, ResultError from core.errors.logger import logger from core.errors.error_shortcuts import not_supported +from core.observers.Notifications import Notifications, EventType def _mark_profile_as_paid(profile_id: int): @@ -62,6 +63,8 @@ def mark_as_paid() -> Result: # finally: payment_cycle.wipe() + notification = "Paid!" + Notifications.emit(EventType.STATUS_UPDATE, notification) return Result(valid=True) diff --git a/core/services/profiles/profile_connections.py b/core/services/profiles/profile_connections.py index fa91237..6c506f8 100644 --- a/core/services/profiles/profile_connections.py +++ b/core/services/profiles/profile_connections.py @@ -1,3 +1,4 @@ +from core.observers.Notifications import Notifications, EventType from core.controllers.ConnectionController import ConnectionController from core.controllers.ProfileController import ProfileController from core.services.networking.systemwide.systemwide_wireguard import terminate_system_connection @@ -152,21 +153,32 @@ def enable_profile( if profile.is_system_profile(): try: connection_result = establish_connection(profile, ignore=ignore, connection_observer=connection_observer) + if not connection_result: + logger.error("Connection result is empty.") + notification = "Connection Failed" + Notifications.emit(EventType.STATUS_UPDATE, notification) + return + if connection_result.valid: if profile_observer is not None: profile_observer.notify('enabled', profile) + notification = "Enabled." + Notifications.emit(EventType.STATUS_UPDATE, notification) return Result(valid=True, data=profile.type) else: - logger.error(f"Couldn't enable the profile: {connection_result.error_type}") error_msg = connection_result.message - raise ProfileActivationError(error_msg) + logger.error(error_msg) + notification = "Connection Failed." + Notifications.emit(EventType.STATUS_UPDATE, notification) + return Result(valid=False, error_type=ResultError.UNKNOWN, message=error_msg) + # raise ProfileActivationError(error_msg) except FirewallError: - raise - except ConnectionError: - raise ProfileActivationError('The profile could not be enabled.') - except ValueError: - raise ProfileActivationError('The profile could not be enabled.') + return Result(valid=False, error_type=ResultError.FIREWALL, message="Firewall Error.") + except ConnectionError as e: + return Result(valid=False, error_type=ResultError.CONNECTION, message=f"Connection Error. {str(e)}") + except ValueError as e: + return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=f"Value Error. {str(e)}") def disable_profile( diff --git a/core/services/subscriptions/subscriptions_by_protocol.py b/core/services/subscriptions/subscriptions_by_protocol.py index 7b7b7cd..3d665eb 100644 --- a/core/services/subscriptions/subscriptions_by_protocol.py +++ b/core/services/subscriptions/subscriptions_by_protocol.py @@ -272,10 +272,6 @@ def return_pre_existing_invoice(billing_code: str, connection_observer: Connecti return result - - - - def get_ticket_invoice(connection_observer: ConnectionObserver) -> Result: """Evaluates if it needs a new ticket invoice, and then returns pre-existing""" @@ -443,20 +439,6 @@ def paying_for_tickets() -> bool: return False -# def start_new_process_if_already(profile_id: int) -# # Is there currently a process? -# payment_cycle = OverallPaymentCycle() - -# if not payment_cycle and not category: -# error_msg = "There is no current billing cycle AND no category provided." -# return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg) - -# billing_data = payment_cycle.billing_data - -# if not billing_data: - - - def get_current_billing_data(category: Optional[PaymentCategory] = None, profile_id: Optional[int] = None) -> Result: """ Goal: diff --git a/core/utils/get_raw_string.py b/core/utils/get_raw_string.py index bd47fdf..a4fae0f 100644 --- a/core/utils/get_raw_string.py +++ b/core/utils/get_raw_string.py @@ -1,5 +1,9 @@ from core.Constants import Constants +from core.errors.logger import logger + import os +from typing import Optional +import subprocess # gets a raw string, but unlike the other function, does not convert it to a JSON. @@ -23,3 +27,50 @@ def get_raw_string(which_ticket, name_of_file_and_folder): return raw_string_content except: return False + + +def _read_with_privileges(filepath: str) -> Optional[str]: + """ + Attempts to read a file using pkexec or sudo to bypass PermissionErrors. + """ + # pkexec is preferred for desktop environments (GUI prompt) + # sudo is the fallback for CLI environments + for cmd_prefix in ["pkexec", "sudo"]: + try: + logger.info(f"Attempting to read {filepath} using {cmd_prefix}...") + # Using a list instead of shell=True prevents shell injection vulnerabilities + result = subprocess.run( + [cmd_prefix, "cat", filepath], + capture_output=True, + text=True, + check=True + ) + return result.stdout + except (subprocess.CalledProcessError, FileNotFoundError) as e: + logger.debug(f"{cmd_prefix} failed or is not installed: {e}") + continue + + return None + +def get_string(filepath: str) -> Optional[str]: + """ + Reads a text file. If a PermissionError occurs on Linux, + attempts to escalate privileges via pkexec or sudo. + """ + try: + with open(filepath, "r", encoding="utf-8") as f: + return f.read() + + except PermissionError: + logger.warning(f"Permission denied for {filepath}. Attempting escalation...") + return _read_with_privileges(filepath) + except FileNotFoundError: + logger.error(f"File not found: {filepath}") + except IsADirectoryError: + logger.error(f"Path is a directory, not a file: {filepath}") + except UnicodeDecodeError as e: + logger.error(f"Encoding error reading file {filepath}: {e}") + except IOError as e: + logger.error(f"IO Error reading file {filepath}: {e}") + + return None