diff --git a/core/models/Result.py b/core/models/Result.py index e31af79..92ef491 100644 --- a/core/models/Result.py +++ b/core/models/Result.py @@ -40,6 +40,7 @@ class ResultError(Enum): WRONG_LOCATION = "wrong_location" SERVER_SIDE = "server_side" UNPAID = "unpaid" + MTU = "mtu" NAVIGATE_TO = "navigate_to" UNKNOWN = "unknown" diff --git a/core/models/manage/server_reply_tools.py b/core/models/manage/server_reply_tools.py index f683788..bc37e9b 100644 --- a/core/models/manage/server_reply_tools.py +++ b/core/models/manage/server_reply_tools.py @@ -1,11 +1,19 @@ +from core.Errors import InvalidSubscriptionError from core.models.manage.validate_dataclass import validate_model from core.models.pydantic_models.ServerReply import ServerReply, ServerError from core.models.Result import Result, ResultError from core.services.networking.api_requests.ApiResponseModel import ApiResponse +from core.observers.ConnectionObserver import ConnectionObserver +from core.services.networking.httpx import connect from core.errors.logger import logger +from enum import Enum from typing import Optional +class ApiCall(Enum): + GET = "get" + POST = "post" + # Translate Server-side to client-side errors in THIS CONTEXT, TRANSLATION_DICT = { ServerError.WRONG_LOCATION: ResultError.WRONG_LOCATION, @@ -79,3 +87,81 @@ def invalid_server_format(error_msg: Optional[str] = None) -> Result: error_msg = "The server gave an invalid response format." return Result(valid=False, error_type=ResultError.INVALID_API_REPLY, message=error_msg) + +def api_call_and_extraction( + desired_type: str, + method: ApiCall, + url: str, + connection_observer: ConnectionObserver, + payload: dict, + billing_code: Optional[str] = None +) -> Result: + """Do an API call, extract the data, and if there's an error, then translate the server's reply to a Result Object""" + + #----------------------------- + # SEND TO API + #----------------------------- + api_response = connect.single_endpoint( + method=method.value, + url=url, + observer=connection_observer, + payload=payload, + billing_code=billing_code + ) + + if not api_response: + error_msg = "Complete failure of the function to produce a valid api response" + logger.error(error_msg) + return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg) + + #----------------------------- + # EVALUATE CONNECTION FAILURE + #----------------------------- + # Layer 1: API failure: + if not api_response.valid: + + # legacy system + 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}") + + error_msg = f"Connection/500s Error prevented the server from replying: {api_response.error_type} {api_response.message}" + logger.error(error_msg) + return Result(valid=False, error_type=ResultError.CONNECTION, message=error_msg) + + #----------------------------- + # EVALUATE SERVER-SIDE ISSUES (assuming it replied) + #----------------------------- + # Layer 2: Validity of Server's Reply: + raw_reply = api_response.data + logger.info("Validating the Validity of the Server's Reply..") + worked, server_reply = validate_model(ServerReply, raw_reply) + + error_msg = f"Invalid Structure for ServerReply! The raw data is {raw_reply}" + + if not worked: + logger.error(error_msg) + return invalid_server_format() + + if not server_reply: + logger.error(error_msg) + return invalid_server_format() + + if not server_reply.valid: + error_message = f"Server reply was correctly structured, but the actual invoice failed to be created. The raw data is {raw_reply}" + logger.error(error_message) + # translate by enum error code: + return translate_server_failure(server_reply) + + if not hasattr(server_reply, desired_type): + error_msg = "Developer Error: You're asking for a data type that isn't in the object!" + logger.error(error_msg) + return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg) + + data = getattr(server_reply, desired_type) + if data: + return Result(valid=True, data=data) + else: + return Result(valid=False, error_type=ResultError.MISSING_DATA, message="Server Replied it was valid, but failed to provide the data.") + + diff --git a/core/models/pydantic_models/ServerReply.py b/core/models/pydantic_models/ServerReply.py index cb41fef..477476e 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 + conf: str = None payment_status: Optional[bool] = None diff --git a/core/services/keys_and_verifications/wireguard_keys.py b/core/services/keys_and_verifications/wireguard_keys.py index 5597c73..a096f1c 100644 --- a/core/services/keys_and_verifications/wireguard_keys.py +++ b/core/services/keys_and_verifications/wireguard_keys.py @@ -1,23 +1,31 @@ -from core.services.networking.httpx import connect -from core.services.networking.api_requests.ApiResponseModel import ErrorType -from core.models.session.SessionProfile import SessionProfile -from core.models.system.SystemProfile import SystemProfile -from core.Errors import MissingSubscriptionError, MissingLocationError, InvalidSubscriptionError + +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 +from core.models.BaseProfile import BaseProfile as Profile +from core.models.BaseProfile import ProfileType + +# from core.models.session.SessionProfile import SessionProfile +# from core.models.system.SystemProfile import SystemProfile +from core.Errors import MissingSubscriptionError, MissingLocationError +#, InvalidSubscriptionError # from core.services.WebServiceApiService import WebServiceApiService # from core.controllers.ConnectionController import ConnectionController from core.observers.ConnectionObserver import ConnectionObserver from core.Constants import Constants from core.errors.logger import logger -from core.models.manage.server_reply_tools import format_server_response +#from core.models.manage.server_reply_tools import format_server_response # from core.models.pydantic_models.ServerReply import ServerReply, ServerError from core.models.Result import Result, ResultError -from typing import Union, Optional +from typing import Optional import base64 import re +MIN_MTU = 1280 + def register_wireguard_session( - profile: Union[SessionProfile, SystemProfile], + profile: Profile, connection_observer: Optional[ConnectionObserver] = None ) -> Result: """Register a WireGuard session for the given profile.""" @@ -34,69 +42,40 @@ def register_wireguard_session( logger.error("No Public Key Generated. Raising ValueError") raise ValueError("No Public Key Generated.") - country_code = profile.location.country_code - location_code = profile.location.code billing_code = profile.subscription.billing_code - url = f'{Constants.SP_API_BASE_URL}/countries/{country_code}/locations/{location_code}/wireguard-sessions' + url = f'{Constants.SP_BASE_URL}/v2/wireguard_config' payload = { 'public_key': public_key } - api_result = connect.single_endpoint( - method="post", + result = api_call_and_extraction( + desired_type="conf", + method=ApiCall.POST, url=url, - observer=connection_observer, + connection_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) + return result wireguard_configuration = result.data if wireguard_configuration is None: - raise InvalidSubscriptionError() + return Result(valid=False, error_type=ResultError.SERVER_SIDE, message="Server gave a blank wireguard config.") wireguard_configuration = _inject_private_key(wireguard_configuration, wireguard_keys.get('private')) + + if profile.type == ProfileType.SESSION: + logger.info("Injecting MTU..") + wireguard_configuration = insert_mtu(mtu=MIN_MTU, wireguard_config=wireguard_configuration) + profile.attach_wireguard_configuration(wireguard_configuration) return Result(valid=True) -# over-engineering: - -# 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.") - - def _generate_wireguard_keys() -> dict: """Generate WireGuard public/private key pair.""" from cryptography.hazmat.primitives import serialization @@ -124,10 +103,84 @@ def _inject_private_key(config: str, private_key: str) -> str: return re.sub(expression, r'\1 ' + private_key, config) +def insert_mtu(mtu: int, wireguard_config: str) -> str: + # This regex finds the DNS line and the whitespace following it, + # specifically when that whitespace is followed by the [Peer] section. + # Group 1: The DNS assignment line + # Group 2: The whitespace (including newlines) between DNS and [Peer] + # Group 3: The [Peer] header + pattern = r"(DNS\s*=\s*[^\n]+)(\s*)(\[Peer\])" + + # We insert the MTU line immediately after the DNS line (\1), + # then preserve the original whitespace (\2) and the [Peer] header (\3). + replacement = rf"\1\nMTU = {mtu}\2\3" + + return re.sub(pattern, replacement, wireguard_config) + + +# If need it pretty spacing quickly: +def expand_newlines(text): + """ + Removes leading/trailing whitespace and ensures exactly one + 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, diff --git a/core/services/networking/systemwide/wireguard/mtu_tools.py b/core/services/networking/systemwide/wireguard/mtu_tools.py new file mode 100644 index 0000000..4c1c1ff --- /dev/null +++ b/core/services/networking/systemwide/wireguard/mtu_tools.py @@ -0,0 +1,86 @@ +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 +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"] + +def test_an_mtu(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: + return Result(valid=False) + + command = ["ping", "-M", "do", "-s", str(payload_size), "-c", "1", url] + 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). + """ + 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) + + if result.valid: + optimal = mid + low = mid + 1 + current_timeout += 1 # Increase timeout as we get closer to max + else: + high = mid - 1 + + return optimal + +def loop_through_testing_mtu(): + # The sequence to probe the range + INITIAL_TEST_LIST = [MAX_MTU, MIDDLE_MTU, MIN_MTU] + + for url in URLS: + logger.info(f"Testing connectivity using URL: {url}") + + 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.") + + logger.critical("All URLs failed MTU tests. Check your WireGuard connection.") + return None diff --git a/core/utils/run_commands.py b/core/utils/run_commands.py index a2ec91a..d7c8ea1 100644 --- a/core/utils/run_commands.py +++ b/core/utils/run_commands.py @@ -185,5 +185,10 @@ def parse_errors(error_output: str) -> ResultError: elif "command not found" in error_output: return ResultError.MISSING_DEPENDENCY + elif "local error: message too long" in error_output: + return ResultError.MTU + else: return ResultError.UNKNOWN + +